Skip to content

Commit fc75e3f

Browse files
committed
Implemented handling of symlinks into tgz
1 parent 0e69e4b commit fc75e3f

File tree

6 files changed

+90
-8
lines changed

6 files changed

+90
-8
lines changed

lib/bootstrap/util/compress.rb

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ def extract(file, target)
7878

7979
if entry.directory?
8080
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)
8188
else # is a file to be extracted
8289
::File.open(target_path, "wb") { |f| f.write(entry.read) }
8390
end
@@ -134,14 +141,32 @@ def gzip(path, target_file)
134141
end
135142
end
136143

137-
# Verifies that a path string is safe for extraction (relative, no `..` traversal).
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).
138162
# Raises CompressError with a specific message if the path is nil/empty, absolute, or
139-
# contains `..`. Does NOT handle symlinks. Works on both Unix and Windows.
163+
# contains `..`. Does NOT handle symlinks, symlinks should be handled on per archive type basis.
164+
# Works on both Unix and Windows.
140165
# @param name [String] path string to validate
141166
# @raise [CompressError] if path is nil, empty, absolute, or traverses with `..`
142167
def self.verify_name_safe!(name)
143168
if name.nil? || name.to_s.strip.empty?
144-
raise CompressError.new("Refusing to extract file to unsafe path. Path cannot be nil or empty.")
169+
raise CompressError.new("Refusing to extract file. Path cannot be nil or empty.")
145170
end
146171
cleanpath = Pathname.new(name).cleanpath
147172
if cleanpath.absolute?

lib/pluginmanager/pack_installer/local.rb

Lines changed: 11 additions & 4 deletions
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_EXTENSION = ".zip"
27+
PACK_EXTENSIONS = [".zip", ".tar.gz"].freeze
2828
LOGSTASH_PATTERN_RE = /logstash\/?/
2929

3030
attr_reader :local_file
@@ -35,7 +35,7 @@ def initialize(local_file)
3535

3636
def execute
3737
raise PluginManager::FileNotFoundError, "Can't file local file #{local_file}" unless ::File.exist?(local_file)
38-
raise PluginManager::InvalidPackError, "Invalid format, the pack must be in zip format" unless valid_format?(local_file)
38+
raise PluginManager::InvalidPackError, "Invalid format, the pack must be in zip or tar.gz format" unless valid_format?(local_file)
3939

4040
PluginManager.ui.info("Installing file: #{local_file}")
4141
uncompressed_path = uncompress(local_file)
@@ -69,16 +69,23 @@ def execute
6969
private
7070
def uncompress(source)
7171
temporary_directory = Stud::Temporary.pathname
72-
LogStash::Util::Zip.extract(source, temporary_directory, LOGSTASH_PATTERN_RE)
72+
if source.downcase.end_with?(".tar.gz")
73+
LogStash::Util::Tar.extract(source, temporary_directory)
74+
else
75+
LogStash::Util::Zip.extract(source, temporary_directory, LOGSTASH_PATTERN_RE)
76+
end
7377
temporary_directory
7478
rescue Zip::Error => e
7579
# OK Zip's handling of file is bit weird, if the file exist but is not a valid zip, it will raise
7680
# a `Zip::Error` exception with a file not found message...
7781
raise InvalidPackError, "Cannot uncompress the zip: #{source}"
82+
rescue LogStash::CompressError => e
83+
raise InvalidPackError, "Cannot uncompress the archive: #{e.message}"
7884
end
7985

8086
def valid_format?(local_file)
81-
::File.extname(local_file).downcase == PACK_EXTENSION
87+
path = local_file.to_s.downcase
88+
PACK_EXTENSIONS.any? { |ext| path.end_with?(ext) }
8289
end
8390
end
8491
end end end

spec/support/pack/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Pack fixtures
2+
3+
## Recreating `pack_with_symlink.tar.gz`
4+
5+
This archive contains a minimal pack layout (logstash/ with one dummy gem) plus a symbolic link, used to test tar.gz extraction with symlink support.
6+
7+
Run the following from the project root (Unix/macOS):
8+
9+
```bash
10+
mkdir -p spec/support/pack/build/logstash
11+
echo "dummy gem content" > spec/support/pack/build/logstash/logstash-input-packtest-0.1.0.gem
12+
echo "content" > spec/support/pack/build/logstash/somefile.txt
13+
ln -s somefile.txt spec/support/pack/build/logstash/link_to_somefile
14+
tar -czf spec/support/pack/pack_with_symlink.tar.gz -C spec/support/pack/build logstash
15+
rm -rf spec/support/pack/build
16+
```
17+
18+
On Windows (PowerShell), create the symlink with appropriate permissions or use a different method to produce a tar that contains a symlink entry; the above sequence is for Unix-like systems.
253 Bytes
Binary file not shown.

spec/unit/plugin_manager/pack_installer/local_spec.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,17 @@
7373
expect { subject.execute }.not_to raise_error
7474
end
7575
end
76+
77+
context "when the pack is valid tar.gz with symlink" do
78+
let(:local_file) { ::File.join(::File.dirname(__FILE__), "..", "..", "..", "support", "pack", "pack_with_symlink.tar.gz") }
79+
80+
it "extracts the archive including the symlink and installs the gems" do
81+
skip("Fixture not found") unless ::File.exist?(local_file)
82+
expect(::Bundler::LogstashInjector).to receive(:inject!).with(be_kind_of(LogStash::PluginManager::PackInstaller::Pack)).and_return([])
83+
expect(::LogStash::PluginManager::GemInstaller).to receive(:install).with(/logstash-input-packtest-0\.1\.0\.gem/, anything).and_return(nil)
84+
85+
expect { subject.execute }.not_to raise_error
86+
end
87+
end
7688
end
7789
end

spec/unit/util/compress_spec.rb

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def list_files(target)
5454
end
5555

5656
describe LogStash::Util do
57-
describe ".verify_name_safe!" do
57+
context "verify entry files destinations" do
5858
it "raises CompressError for nil or empty path" do
5959
expect { LogStash::Util.verify_name_safe!(nil) }.to raise_error(LogStash::CompressError, /Path cannot be nil or empty/)
6060
expect { LogStash::Util.verify_name_safe!("") }.to raise_error(LogStash::CompressError, /Path cannot be nil or empty/)
@@ -218,6 +218,26 @@ def list_files(target)
218218
expect(FileUtils).to receive(:mkdir).with(target)
219219
expect { subject.extract(source, target) }.to raise_error(LogStash::CompressError, /Refusing to extract file to unsafe path.*Files may not traverse with `..`/)
220220
end
221+
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")
224+
skip("Fixture not found") unless ::File.exist?(fixture)
225+
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/)
240+
end
221241
end
222242

223243
context "#compression" do

0 commit comments

Comments
 (0)