Skip to content

Commit cbbf6d9

Browse files
committed
Moved tar.gz test to the geoip downloader part and avoid to support symlink just raising an error when a tgz contains a symlink
1 parent 18be6d9 commit cbbf6d9

File tree

6 files changed

+36
-61
lines changed

6 files changed

+36
-61
lines changed

lib/bootstrap/util/compress.rb

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -74,18 +74,12 @@ def extract(file, target)
7474
::Gem::Package::TarReader.new(gzip_file) do |tar_file|
7575
tar_file.each do |entry|
7676
LogStash::Util.verify_name_safe!(entry.full_name)
77+
LogStash::Util.verify_tar_link_entry!(entry)
7778
target_path = ::File.join(target, entry.full_name)
7879

7980
if entry.directory?
8081
FileUtils.mkdir_p(target_path)
81-
elsif entry.symlink?
82-
linkname = entry.header.linkname
83-
unless LogStash::Util.symlink_target_safe?(linkname, target_path, target)
84-
raise CompressError.new("Refusing to extract symlink with unsafe target: #{entry.full_name} -> #{linkname}. Symlink target must remain inside extraction directory.")
85-
end
86-
FileUtils.mkdir_p(::File.dirname(target_path))
87-
::File.symlink(linkname, target_path)
88-
else # is a file to be extracted
82+
else # is a file to be extracted (symlinks rejected in verify_tar_entry_safe!)
8983
::File.open(target_path, "wb") { |f| f.write(entry.read) }
9084
end
9185
end
@@ -141,32 +135,12 @@ def gzip(path, target_file)
141135
end
142136
end
143137

144-
# Returns true if a symlink target (linkname) would resolve to a path under extraction_root
145-
# when the symlink is created at symlink_path. Works on both Unix and Windows.
146-
# @param linkname [String] symlink target (relative or absolute)
147-
# @param symlink_path [String] full path where the symlink will be created
148-
# @param extraction_root [String] root directory all paths must stay under
149-
# @return [Boolean] true if resolved path is under extraction_root
150-
def self.symlink_target_safe?(linkname, symlink_path, extraction_root)
151-
return false if linkname.nil? || linkname.to_s.strip.empty?
152-
symlink_dir = ::File.dirname(symlink_path)
153-
resolved = Pathname.new(::File.expand_path(linkname, symlink_dir)).cleanpath
154-
root = Pathname.new(::File.expand_path(extraction_root)).cleanpath
155-
!resolved.relative_path_from(root).to_s.start_with?("..")
156-
rescue ArgumentError
157-
# relative_path_from raises if resolved is not under root
158-
false
159-
end
160-
161-
# Verifies that a path string is safe for extraction (relative, no parents traversal).
162-
# Raises CompressError with a specific message if the path is nil/empty, absolute, or
163-
# contains `..`. Does NOT handle symlinks, symlinks should be handled on per archive type basis.
164-
# Works on both Unix and Windows.
165-
# @param name [String] path string to validate
166-
# @raise [CompressError] if path is nil, empty, absolute, or traverses with `..`
138+
# Verifies a path string is safe for zip or tar entry names (relative, no `..` traversal).
139+
# @param name [String] archive entry path (e.g. Zip entry name or tar full_name)
140+
# @raise [CompressError] if name is nil, empty, absolute, or traverses with `..`
167141
def self.verify_name_safe!(name)
168142
if name.nil? || name.to_s.strip.empty?
169-
raise CompressError.new("Refusing to extract file. Path cannot be nil or empty.")
143+
raise CompressError.new("Refusing to extract file to unsafe path. Path cannot be nil or empty.")
170144
end
171145
cleanpath = Pathname.new(name).cleanpath
172146
if cleanpath.absolute?
@@ -176,5 +150,15 @@ def self.verify_name_safe!(name)
176150
raise CompressError.new("Refusing to extract file to unsafe path: #{name}. Files may not traverse with `..`")
177151
end
178152
end
153+
154+
# Tar.gz-only: rejects symlink entries
155+
# @param entry [Gem::Package::TarReader::Entry]
156+
# @raise [CompressError] if entry is a symlink or path is unsafe
157+
def self.verify_tar_link_entry!(entry)
158+
if entry.symlink?
159+
raise CompressError.new("Refusing to extract symlink entry: #{entry.full_name}")
160+
end
161+
end
162+
#private :verify_tar_link_entry!
179163
end
180164
end

lib/pluginmanager/pack_installer/local.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
module LogStash module PluginManager module PackInstaller
2626
class Local
27-
PACK_EXTENSIONS = ".zip"
27+
PACK_EXTENSION = ".zip"
2828
LOGSTASH_PATTERN_RE = /logstash\/?/
2929

3030
attr_reader :local_file

spec/unit/util/compress_spec.rb

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ def list_files(target)
7878
expect { LogStash::Util.verify_name_safe!("single") }.not_to raise_error
7979
end
8080
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
8192
end
8293

8394
describe LogStash::Util::Zip do
@@ -197,7 +208,7 @@ def list_files(target)
197208

198209
let(:tar_file) do
199210
["foo", "bar", "zoo"].inject([]) do |acc, name|
200-
acc << OpenStruct.new(:full_name => name)
211+
acc << OpenStruct.new(:full_name => name, :symlink? => false, :directory? => false)
201212
acc
202213
end
203214
end
@@ -212,31 +223,18 @@ def list_files(target)
212223
end
213224

214225
it "raises CompressError when an entry path traverses with .." do
215-
tar_with_evil = [OpenStruct.new(:full_name => "../evil", :directory? => false, :read => "")]
226+
tar_with_evil = [OpenStruct.new(:full_name => "../evil", :directory? => false, :symlink? => false, :read => "")]
216227
allow(Zlib::GzipReader).to receive(:open).with(source).and_yield(gzip_file)
217228
allow(Gem::Package::TarReader).to receive(:new).with(gzip_file).and_yield(tar_with_evil)
218229
expect(FileUtils).to receive(:mkdir).with(target)
219230
expect { subject.extract(source, target) }.to raise_error(LogStash::CompressError, /Refusing to extract file to unsafe path.*Files may not traverse with `..`/)
220231
end
221232

222-
it "extracts a tar.gz containing a symlink and creates the symlink" do
223-
fixture = ::File.join(::File.dirname(__FILE__), "..", "..", "support", "pack", "pack_with_symlink.tar.gz")
233+
it "raises CompressError when tarball contains a symlink entry" do
234+
fixture = ::File.expand_path("../../../x-pack/spec/geoip_database_management/fixtures/sample_with_symlink.tgz", ::File.dirname(__FILE__))
224235
skip("Fixture not found") unless ::File.exist?(fixture)
225236
target_dir = Stud::Temporary.pathname
226-
subject.extract(fixture, target_dir)
227-
symlink_path = ::File.join(target_dir, "logstash", "link_to_somefile")
228-
expect(::File.symlink?(symlink_path)).to be true
229-
expect(::File.readlink(symlink_path)).to eq("somefile.txt")
230-
end
231-
232-
it "raises CompressError when a symlink target would escape the extraction directory" do
233-
header = OpenStruct.new(:typeflag => "2", :linkname => "../../etc/passwd")
234-
entry = OpenStruct.new(:full_name => "logstash/link_evil", :directory? => false, :symlink? => true, :header => header, :read => nil)
235-
tar_with_evil_symlink = [entry]
236-
allow(Zlib::GzipReader).to receive(:open).with(source).and_yield(gzip_file)
237-
allow(Gem::Package::TarReader).to receive(:new).with(gzip_file).and_yield(tar_with_evil_symlink)
238-
expect(FileUtils).to receive(:mkdir).with(target)
239-
expect { subject.extract(source, target) }.to raise_error(LogStash::CompressError, /Refusing to extract symlink with unsafe target/)
237+
expect { subject.extract(fixture, target_dir) }.to raise_error(LogStash::CompressError, /Refusing to extract symlink entry:.*GeoLite2-City-alias\.mmdb/)
240238
end
241239
end
242240

x-pack/spec/geoip_database_management/downloader_spec.rb

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -189,18 +189,11 @@
189189
expect(::File.exist?(folder_less_path)).to be_truthy
190190
end
191191

192-
it "extracts tarball containing a symlink and preserves the symlink" do
192+
it "raises when tarball contains a symlink entry" do
193193
zip_path = ::File.expand_path("./fixtures/sample_with_symlink.tgz", ::File.dirname(__FILE__))
194194
skip("sample_with_symlink.tgz not found") unless ::File.exist?(zip_path)
195195

196-
new_db_path = downloader.send(:unzip, database_type, dirname, zip_path)
197-
198-
expect(new_db_path).to match /GeoLite2-#{database_type}\.mmdb/
199-
expect(::File.exist?(new_db_path)).to be_truthy
200-
201-
alias_path = data_path.resolve(dirname, "GeoLite2-City-alias.mmdb")
202-
expect(::File.symlink?(alias_path)).to be true
203-
expect(::File.readlink(alias_path)).to eq("GeoLite2-City.mmdb")
196+
expect { downloader.send(:unzip, database_type, dirname, zip_path) }.to raise_error(LogStash::CompressError, /Refusing to extract symlink entry/)
204197
end
205198
end
206199

x-pack/spec/geoip_database_management/fixtures/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Recreating `sample_with_symlink.tgz`
44

5-
This archive is the same as `sample.tgz` plus a symbolic link `GeoLite2-City-alias.mmdb``GeoLite2-City.mmdb` at the archive root. Run from this directory (Unix/macOS):
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):
66

77
```bash
88
cd "$(dirname "$0")" # or: cd x-pack/spec/geoip_database_management/fixtures
Binary file not shown.

0 commit comments

Comments
 (0)