safe_zip 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7bec069256c55d64e3d1a1e531a8155d3265720004b411e4eff72d631918caa3
4
+ data.tar.gz: a4cf744f09bb187b005db88b74b8a48e741b3ae37ef91984fbd5e5b26b5bfbdf
5
+ SHA512:
6
+ metadata.gz: ad1abccda8aebebdad8a668ab03350415e0ccd56fc24c05016328f829d234c1bb1ca37557640f3031d11f948a6e8ec065d02496a6066949776965ce6f4a528bc
7
+ data.tar.gz: fc0146896ee13019ffa295672b9500caf925f6dc69ab6cda521fb5d680fff157089a10bb15355924933d7a5cceaf61fe0b8bf3af1fe7cc9729d2f59e9a695c5d
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeZip
4
+ class Entry
5
+ attr_reader :zip_archive, :zip_entry
6
+ attr_reader :path, :params
7
+
8
+ def initialize(zip_archive, zip_entry, params)
9
+ @zip_archive = zip_archive
10
+ @zip_entry = zip_entry
11
+ @params = params
12
+ @path = ::File.expand_path(zip_entry.name, params.extract_path)
13
+ end
14
+
15
+ def path_dir
16
+ ::File.dirname(path)
17
+ end
18
+
19
+ def real_path_dir
20
+ ::File.realpath(path_dir)
21
+ end
22
+
23
+ def exist?
24
+ ::File.exist?(path)
25
+ end
26
+
27
+ def extract
28
+ # do not extract if file is not part of target directory or target file
29
+ return false unless matching_target_directory || matching_target_file
30
+
31
+ # do not overwrite existing file
32
+ raise SafeZip::Extract::AlreadyExistsError, "File already exists #{zip_entry.name}" if exist?
33
+
34
+ create_path_dir
35
+
36
+ if zip_entry.file?
37
+ extract_file
38
+ elsif zip_entry.directory?
39
+ extract_dir
40
+ elsif zip_entry.symlink?
41
+ extract_symlink
42
+ else
43
+ raise SafeZip::Extract::UnsupportedEntryError, "File #{zip_entry.name} cannot be extracted"
44
+ end
45
+ rescue SafeZip::Extract::Error
46
+ raise
47
+ rescue Zip::EntrySizeError => e
48
+ raise SafeZip::Extract::EntrySizeError, e.message
49
+ rescue StandardError => e
50
+ raise SafeZip::Extract::ExtractError, e.message
51
+ end
52
+
53
+ private
54
+
55
+ def extract_file
56
+ zip_archive.extract(zip_entry, path)
57
+ end
58
+
59
+ def extract_dir
60
+ FileUtils.mkdir(path)
61
+ end
62
+
63
+ def extract_symlink
64
+ source_path = read_symlink
65
+ real_source_path = expand_symlink(source_path)
66
+
67
+ # ensure that source path of symlink is within target directories
68
+ unless real_source_path.start_with?(matching_target_directory)
69
+ raise SafeZip::Extract::PermissionDeniedError, "Symlink cannot be created targeting: #{source_path}"
70
+ end
71
+
72
+ ::File.symlink(source_path, path)
73
+ end
74
+
75
+ def create_path_dir
76
+ # Create all directories, but ignore permissions
77
+ FileUtils.mkdir_p(path_dir)
78
+
79
+ # disallow to make path dirs to point to another directories
80
+ unless path_dir == real_path_dir
81
+ raise SafeZip::Extract::PermissionDeniedError, "Directory of #{zip_entry.name} points to another directory"
82
+ end
83
+ end
84
+
85
+ def matching_target_directory
86
+ params.matching_target_directory(path)
87
+ end
88
+
89
+ def matching_target_file
90
+ params.matching_target_file(path)
91
+ end
92
+
93
+ def read_symlink
94
+ zip_archive.read(zip_entry)
95
+ end
96
+
97
+ def expand_symlink(source_path)
98
+ ::File.realpath(source_path, path_dir)
99
+ rescue StandardError
100
+ raise SafeZip::Extract::SymlinkSourceDoesNotExistError, "Symlink source #{source_path} does not exist"
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeZip
4
+ # SafeZip::Extract provides a safe interface
5
+ # to extract specific directories or files within a `zip` archive.
6
+ #
7
+ # @example Extract directories to destination
8
+ # SafeZip::Extract.new(archive_file).extract(directories: ['app/', 'test/'], to: destination_path)
9
+ # @example Extract files to destination
10
+ # SafeZip::Extract.new(archive_file).extract(files: ['index.html', 'app/index.js'], to: destination_path)
11
+ class Extract
12
+ Error = Class.new(StandardError)
13
+ PermissionDeniedError = Class.new(Error)
14
+ SymlinkSourceDoesNotExistError = Class.new(Error)
15
+ UnsupportedEntryError = Class.new(Error)
16
+ EntrySizeError = Class.new(Error)
17
+ AlreadyExistsError = Class.new(Error)
18
+ NoMatchingError = Class.new(Error)
19
+ ExtractError = Class.new(Error)
20
+
21
+ attr_reader :archive_path
22
+
23
+ def initialize(archive_file)
24
+ @archive_path = archive_file
25
+ end
26
+
27
+ # extract given files or directories from the archive into the destination path
28
+ #
29
+ # @param [Hash] opts the options for extraction.
30
+ # @option opts [Array<String] :files list of files to be extracted
31
+ # @option opts [Array<String] :directories list of directories to be extracted
32
+ # @option opts [String] :to destination path
33
+ #
34
+ # @raise [PermissionDeniedError]
35
+ # @raise [SymlinkSourceDoesNotExistError]
36
+ # @raise [UnsupportedEntryError]
37
+ # @raise [EntrySizeError]
38
+ # @raise [AlreadyExistsError]
39
+ # @raise [NoMatchingError]
40
+ # @raise [ExtractError]
41
+ def extract(opts = {})
42
+ params = SafeZip::ExtractParams.new(**opts)
43
+
44
+ extract_with_ruby_zip(params)
45
+ end
46
+
47
+ private
48
+
49
+ def extract_with_ruby_zip(params)
50
+ ::Zip::File.open(archive_path) do |zip_archive| # rubocop:disable Performance/Rubyzip
51
+ # Extract all files in the following order:
52
+ # 1. Directories first,
53
+ # 2. Files next,
54
+ # 3. Symlinks last (or anything else)
55
+ extracted = extract_all_entries(zip_archive, params,
56
+ zip_archive.lazy.select(&:directory?))
57
+
58
+ extracted += extract_all_entries(zip_archive, params,
59
+ zip_archive.lazy.select(&:file?))
60
+
61
+ extracted += extract_all_entries(zip_archive, params,
62
+ zip_archive.lazy.reject(&:directory?).reject(&:file?))
63
+
64
+ raise NoMatchingError, 'No entries extracted' unless extracted > 0
65
+ end
66
+ end
67
+
68
+ def extract_all_entries(zip_archive, params, entries)
69
+ entries.count do |zip_entry|
70
+ SafeZip::Entry.new(zip_archive, zip_entry, params)
71
+ .extract
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeZip
4
+ class ExtractParams
5
+ attr_reader :directories, :files, :extract_path
6
+
7
+ def initialize(to:, directories: [], files: [])
8
+ @directories = directories
9
+ @files = files
10
+ @extract_path = ::File.realpath(to)
11
+ validate!
12
+ end
13
+
14
+ def matching_target_directory(path)
15
+ target_directories.find do |directory|
16
+ path.start_with?(directory)
17
+ end
18
+ end
19
+
20
+ def target_directories
21
+ @target_directories ||= directories.map do |directory|
22
+ ::File.join(::File.expand_path(directory, extract_path), '')
23
+ end
24
+ end
25
+
26
+ def directories_wildcard
27
+ @directories_wildcard ||= directories.map do |directory|
28
+ ::File.join(directory, '*')
29
+ end
30
+ end
31
+
32
+ def matching_target_file(path)
33
+ target_files.include?(path)
34
+ end
35
+
36
+ private
37
+
38
+ def target_files
39
+ @target_files ||= files.map do |file|
40
+ ::File.join(extract_path, file)
41
+ end
42
+ end
43
+
44
+ def validate!
45
+ raise ArgumentError, 'Either directories or files are required' if directories.empty? && files.empty?
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeZip
4
+ VERSION = "0.1.0"
5
+ end
data/lib/safe_zip.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+
5
+ require_relative "safe_zip/version"
6
+ require_relative "safe_zip/extract_params"
7
+ require_relative "safe_zip/entry"
8
+ require_relative "safe_zip/extract"
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: safe_zip
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - GitLab Engineers
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rubyzip
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.4'
26
+ - !ruby/object:Gem::Dependency
27
+ name: gitlab-styles
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '14.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '14.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec-parameterized
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.0'
68
+ description: Provides a safe interface to extract specific directories or files within
69
+ a zip archive, preventing path traversal attacks.
70
+ email:
71
+ - engineering@gitlab.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - lib/safe_zip.rb
77
+ - lib/safe_zip/entry.rb
78
+ - lib/safe_zip/extract.rb
79
+ - lib/safe_zip/extract_params.rb
80
+ - lib/safe_zip/version.rb
81
+ homepage: https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems/safe_zip
82
+ licenses:
83
+ - MIT
84
+ metadata:
85
+ rubygems_mfa_required: 'true'
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '3.0'
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 4.0.15
101
+ specification_version: 4
102
+ summary: Safe zip archive extraction
103
+ test_files: []