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 +7 -0
- data/lib/safe_zip/entry.rb +103 -0
- data/lib/safe_zip/extract.rb +75 -0
- data/lib/safe_zip/extract_params.rb +48 -0
- data/lib/safe_zip/version.rb +5 -0
- data/lib/safe_zip.rb +8 -0
- metadata +103 -0
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
|
data/lib/safe_zip.rb
ADDED
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: []
|