Skip to content

Commit 6f9ea19

Browse files
mergify[bot]andsel
andauthored
Adds verification of the file name (#18848) (#18909)
Verify the destination path to check some rules that avoid deliberate path traversal during extraction. - avoid absolute path - avoid symbolic links (only for tar.gz file) - avoid paths with parent navigation, for example: `logstash/../../../something/in/target/host` Covered all these branches with unit tests. (cherry picked from commit c4d7362) Co-authored-by: Andrea Selva <selva.andre@gmail.com>
1 parent feeab04 commit 6f9ea19

File tree

6 files changed

+129
-11
lines changed

6 files changed

+129
-11
lines changed

lib/bootstrap/util/compress.rb

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
require "fileutils"
2121
require "zlib"
2222
require "stud/temporary"
23+
require "pathname"
2324

2425
module LogStash
2526
class CompressError < StandardError; end
@@ -36,9 +37,11 @@ def extract(source, target, pattern = nil)
3637
raise CompressError.new("Directory #{target} exist") if ::File.exist?(target)
3738
::Zip::File.open(source) do |zip_file|
3839
zip_file.each do |file|
39-
path = ::File.join(target, file.name)
40+
LogStash::Util.verify_name_safe!(file.name)
41+
safe_name = Pathname.new(file.name).cleanpath.to_s
42+
path = ::File.join(target, safe_name)
4043
FileUtils.mkdir_p(::File.dirname(path))
41-
zip_file.extract(file, path) if pattern.nil? || pattern =~ file.name
44+
zip_file.extract(file, path) if pattern.nil? || pattern =~ safe_name
4245
end
4346
end
4447
end
@@ -72,11 +75,14 @@ def extract(file, target)
7275
Zlib::GzipReader.open(file) do |gzip_file|
7376
::Gem::Package::TarReader.new(gzip_file) do |tar_file|
7477
tar_file.each do |entry|
78+
LogStash::Util.verify_name_safe!(entry.full_name)
79+
LogStash::Util.verify_tar_link_entry!(entry)
7580
target_path = ::File.join(target, entry.full_name)
7681

7782
if entry.directory?
7883
FileUtils.mkdir_p(target_path)
79-
else # is a file to be extracted
84+
else # is a file to be extracted (symlinks rejected in verify_tar_link_entry!)
85+
FileUtils.mkdir_p(::File.dirname(target_path))
8086
::File.open(target_path, "wb") { |f| f.write(entry.read) }
8187
end
8288
end
@@ -131,5 +137,30 @@ def gzip(path, target_file)
131137
end
132138
end
133139
end
140+
141+
# Verifies a path string is safe for zip or tar entry names (relative, no `..` traversal).
142+
# @param name [String] archive entry path (e.g. Zip entry name or tar full_name)
143+
# @raise [CompressError] if name is nil, empty, absolute, or traverses with `..`
144+
def self.verify_name_safe!(name)
145+
if name.nil? || name.to_s.strip.empty?
146+
raise CompressError.new("Refusing to extract file to unsafe path. Path cannot be nil or empty.")
147+
end
148+
cleanpath = Pathname.new(name).cleanpath
149+
if cleanpath.absolute?
150+
raise CompressError.new("Refusing to extract file to unsafe path: #{name}. Absolute paths are not allowed.")
151+
end
152+
if cleanpath.each_filename.to_a.include?("..")
153+
raise CompressError.new("Refusing to extract file to unsafe path: #{name}. Files may not traverse with `..`")
154+
end
155+
end
156+
157+
# Tar.gz-only: rejects symlink entries
158+
# @param entry [Gem::Package::TarReader::Entry]
159+
# @raise [CompressError] if entry is a symlink
160+
def self.verify_tar_link_entry!(entry)
161+
if entry.symlink?
162+
raise CompressError.new("Refusing to extract symlink entry: #{entry.full_name}")
163+
end
164+
end
134165
end
135166
end

lib/pluginmanager/pack_installer/local.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ def uncompress(source)
7575
# OK Zip's handling of file is bit weird, if the file exist but is not a valid zip, it will raise
7676
# a `Zip::Error` exception with a file not found message...
7777
raise InvalidPackError, "Cannot uncompress the zip: #{source}"
78+
rescue LogStash::CompressError => e
79+
raise InvalidPackError, "Cannot uncompress the zip #{source}: #{e.message}"
7880
end
7981

8082
def valid_format?(local_file)

spec/unit/util/compress_spec.rb

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,44 @@ def list_files(target)
5353
Dir.glob(::File.join(target, "**", "*")).select { |f| ::File.file?(f) }.size
5454
end
5555

56+
describe LogStash::Util do
57+
context "verify entry files destinations" do
58+
it "raises CompressError for nil or empty path" do
59+
expect { LogStash::Util.verify_name_safe!(nil) }.to raise_error(LogStash::CompressError, /Path cannot be nil or empty/)
60+
expect { LogStash::Util.verify_name_safe!("") }.to raise_error(LogStash::CompressError, /Path cannot be nil or empty/)
61+
expect { LogStash::Util.verify_name_safe!(" ") }.to raise_error(LogStash::CompressError, /Path cannot be nil or empty/)
62+
end
63+
64+
it "raises CompressError for absolute paths" do
65+
expect { LogStash::Util.verify_name_safe!("/etc/passwd") }.to raise_error(LogStash::CompressError, /Absolute paths are not allowed/)
66+
expect { LogStash::Util.verify_name_safe!("/foo/bar") }.to raise_error(LogStash::CompressError, /Absolute paths are not allowed/)
67+
end
68+
69+
it "raises CompressError for relative paths that traverse with .." do
70+
expect { LogStash::Util.verify_name_safe!("../foo") }.to raise_error(LogStash::CompressError, /Files may not traverse with `..`/)
71+
expect { LogStash::Util.verify_name_safe!("..") }.to raise_error(LogStash::CompressError, /Files may not traverse with `..`/)
72+
expect { LogStash::Util.verify_name_safe!("a/../../etc") }.to raise_error(LogStash::CompressError, /Files may not traverse with `..`/)
73+
end
74+
75+
it "does not raise for safe relative paths" do
76+
expect { LogStash::Util.verify_name_safe!("foo/bar") }.not_to raise_error
77+
expect { LogStash::Util.verify_name_safe!("a/b/c") }.not_to raise_error
78+
expect { LogStash::Util.verify_name_safe!("single") }.not_to raise_error
79+
end
80+
end
81+
82+
context "#verify_tar_link_entry!" do
83+
def tar_entry(full_name, symlink: false, directory: false)
84+
OpenStruct.new(:full_name => full_name, :symlink? => symlink, :directory? => directory)
85+
end
86+
87+
it "raises CompressError for symlink entries without path validation" do
88+
entry = tar_entry("logstash/link", symlink: true)
89+
expect { LogStash::Util.verify_tar_link_entry!(entry) }.to raise_error(LogStash::CompressError, /Refusing to extract symlink entry: logstash\/link/)
90+
end
91+
end
92+
end
93+
5694
describe LogStash::Util::Zip do
5795
subject { Class.new { extend LogStash::Util::Zip } }
5896

@@ -79,6 +117,12 @@ def list_files(target)
79117
subject.extract(source, target)
80118
end
81119

120+
it "raises CompressError when an entry path traverses with .." do
121+
zip_with_evil = [OpenStruct.new(:name => "../evil")]
122+
allow(Zip::File).to receive(:open).with(source).and_yield(zip_with_evil)
123+
expect { subject.extract(source, target) }.to raise_error(LogStash::CompressError, /Refusing to extract file to unsafe path.*Files may not traverse with `..`/)
124+
end
125+
82126
context "patterns" do
83127
# Theses tests sound duplicated but they are actually better than the other one
84128
# since they do not involve any mocks.
@@ -153,7 +197,11 @@ def list_files(target)
153197

154198
context "#extraction" do
155199
let(:source) { File.join(File.expand_path("."), "source_file.tar.gz") }
156-
let(:target) { File.expand_path("target_dir") }
200+
let(:target) { Stud::Temporary.pathname }
201+
202+
after do
203+
FileUtils.rm_rf(target) if File.exist?(target)
204+
end
157205

158206
it "raise an exception if the target dir exist" do
159207
allow(File).to receive(:exist?).with(target).and_return(true)
@@ -164,7 +212,7 @@ def list_files(target)
164212

165213
let(:tar_file) do
166214
["foo", "bar", "zoo"].inject([]) do |acc, name|
167-
acc << OpenStruct.new(:full_name => name)
215+
acc << OpenStruct.new(:full_name => name, :symlink? => false, :directory? => false)
168216
acc
169217
end
170218
end
@@ -173,10 +221,26 @@ def list_files(target)
173221
allow(Zlib::GzipReader).to receive(:open).with(source).and_yield(gzip_file)
174222
allow(Gem::Package::TarReader).to receive(:new).with(gzip_file).and_yield(tar_file)
175223

224+
allow(FileUtils).to receive(:mkdir_p)
176225
expect(FileUtils).to receive(:mkdir).with(target)
177226
expect(File).to receive(:open).exactly(3).times
178227
subject.extract(source, target)
179228
end
229+
230+
it "raises CompressError when an entry path traverses with .." do
231+
tar_with_evil = [OpenStruct.new(:full_name => "../evil", :directory? => false, :symlink? => false, :read => "")]
232+
allow(Zlib::GzipReader).to receive(:open).with(source).and_yield(gzip_file)
233+
allow(Gem::Package::TarReader).to receive(:new).with(gzip_file).and_yield(tar_with_evil)
234+
allow(FileUtils).to receive(:mkdir_p)
235+
expect(FileUtils).to receive(:mkdir).with(target)
236+
expect { subject.extract(source, target) }.to raise_error(LogStash::CompressError, /Refusing to extract file to unsafe path.*Files may not traverse with `..`/)
237+
end
238+
239+
it "raises CompressError when tarball contains a symlink entry" do
240+
fixture = ::File.expand_path("../../../x-pack/spec/geoip_database_management/fixtures/sample_with_symlink.tgz", ::File.dirname(__FILE__))
241+
expect(::File).to exist(fixture)
242+
expect { subject.extract(fixture, target) }.to raise_error(LogStash::CompressError, /Refusing to extract symlink entry:.*GeoLite2-City-alias\.mmdb/)
243+
end
180244
end
181245

182246
context "#compression" do

x-pack/spec/geoip_database_management/downloader_spec.rb

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,10 @@
154154

155155
context "with matching md5 checksum" do
156156
let(:md5_hash) { LogStash::GeoipDatabaseManagement::Util.md5(sample_city_db_gz) }
157-
it "should download file and return zip path" do
158-
new_zip_path = downloader.send(:download_database, database_type, dirname, db_info)
159-
expect(new_zip_path).to match /GeoLite2-City\.tgz/
160-
expect(::File.exist?(new_zip_path)).to be_truthy
157+
it "should download file and return tgz path" do
158+
new_tgz_path = downloader.send(:download_database, database_type, dirname, db_info)
159+
expect(new_tgz_path).to match /GeoLite2-City\.tgz/
160+
expect(::File.exist?(new_tgz_path)).to be_truthy
161161
end
162162
end
163163
end
@@ -176,8 +176,8 @@
176176
end
177177

178178
it "should extract all files in tarball" do
179-
zip_path = ::File.expand_path("./fixtures/sample.tgz", ::File.dirname(__FILE__))
180-
new_db_path = downloader.send(:unzip, database_type, dirname, zip_path)
179+
tgz_path = ::File.expand_path("./fixtures/sample.tgz", ::File.dirname(__FILE__))
180+
new_db_path = downloader.send(:unzip, database_type, dirname, tgz_path)
181181

182182
expect(new_db_path).to match /GeoLite2-#{database_type}\.mmdb/
183183
expect(::File.exist?(new_db_path)).to be_truthy
@@ -188,6 +188,13 @@
188188
expect(::File.exist?(folder_more_path)).to be_truthy
189189
expect(::File.exist?(folder_less_path)).to be_truthy
190190
end
191+
192+
it "raises when tarball contains a symlink entry" do
193+
tgz_path = ::File.expand_path("./fixtures/sample_with_symlink.tgz", ::File.dirname(__FILE__))
194+
expect(::File.exist?(tgz_path)).to be_truthy
195+
196+
expect { downloader.send(:unzip, database_type, dirname, tgz_path) }.to raise_error(LogStash::CompressError, /Refusing to extract symlink entry/)
197+
end
191198
end
192199

193200
context "assert_database!" do
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# GeoIP downloader spec fixtures
2+
3+
## Recreating `sample_with_symlink.tgz`
4+
5+
This archive is the same as `sample.tgz` plus a symbolic link `GeoLite2-City-alias.mmdb``GeoLite2-City.mmdb` at the archive root. `LogStash::Util::Tar.extract` rejects symlink entries, so this fixture is only for specs that assert that behavior. Run from this directory (Unix/macOS):
6+
7+
```bash
8+
# From the repository root:
9+
cd x-pack/spec/geoip_database_management/fixtures
10+
rm -rf _symlink_build && mkdir _symlink_build && tar -xzf sample.tgz -C _symlink_build
11+
ln -s GeoLite2-City.mmdb _symlink_build/GeoLite2-City-alias.mmdb
12+
tar -czf sample_with_symlink.tgz -C _symlink_build .
13+
rm -rf _symlink_build
14+
```
Binary file not shown.

0 commit comments

Comments
 (0)