Skip to content

Commit 20e729f

Browse files
hsbtclaude
andcommitted
Check the resolved parent directory before extracting old format gems
Gem::Package::Old#extract_files only validated entry paths by string expansion, so a preexisting symlink in the extraction directory redirected writes outside of it. Gem::Package#extract_tar_gz already re-resolves the parent directory with File.realpath, so extract the shared check into verify_extraction_dir and call it from both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 27261bc commit 20e729f

3 files changed

Lines changed: 35 additions & 6 deletions

File tree

lib/rubygems/package.rb

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -453,10 +453,7 @@ def extract_tar_gz(io, destination_dir, pattern = "*") # :nodoc:
453453
directories << mkdir
454454
end
455455

456-
real_mkdir = File.realpath(mkdir)
457-
unless real_mkdir == destination_dir || normalize_path(real_mkdir).start_with?(normalize_path(destination_dir + "/"))
458-
raise Gem::Package::PathError.new(real_mkdir, destination_dir)
459-
end
456+
verify_extraction_dir mkdir, destination_dir
460457

461458
if entry.file?
462459
File.open(destination, "wb") do |out|
@@ -662,6 +659,20 @@ def install_location(filename, destination_dir) # :nodoc:
662659
destination
663660
end
664661

662+
##
663+
# Raises an exception unless +dir+, with symlinks resolved, is
664+
# +destination_dir+ or a directory inside it. +destination_dir+ must
665+
# already be resolved with File.realpath by the caller.
666+
667+
def verify_extraction_dir(dir, destination_dir) # :nodoc:
668+
real_dir = File.realpath(dir)
669+
670+
return if real_dir == destination_dir ||
671+
normalize_path(real_dir).start_with?(normalize_path(destination_dir + "/"))
672+
673+
raise Gem::Package::PathError.new(real_dir, destination_dir)
674+
end
675+
665676
##
666677
# Verifies the +checksums+ against the +digests+. This check is not
667678
# cryptographically secure. Missing checksums are ignored.

lib/rubygems/package/old.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,12 @@ def extract_files(destination_dir)
7979
raise Gem::Package::FormatError, "#{full_name} in #{@gem} is corrupt" if
8080
file_data.length != entry["size"].to_i
8181

82-
FileUtils.rm_rf destination
83-
8482
FileUtils.mkdir_p File.dirname(destination), mode: dir_mode && 0o755
8583

84+
verify_extraction_dir File.dirname(destination), destination_dir
85+
86+
FileUtils.rm_rf destination
87+
8688
File.open destination, "wb", file_mode(entry["mode"]) do |out|
8789
out.write file_data
8890
end

test/rubygems/test_gem_package_old.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,22 @@ def test_extract_files
4444
assert_equal mask, File.stat(extracted).mode unless Gem.win_platform?
4545
end
4646

47+
def test_extract_files_rejects_preexisting_symlink_escape
48+
omit "Symlinks not supported or not enabled" unless symlink_supported?
49+
50+
escape_dir = File.join @tempdir, "escape"
51+
FileUtils.mkdir_p escape_dir
52+
53+
File.symlink escape_dir, File.join(@destination, "lib")
54+
55+
assert_raise Gem::Package::PathError do
56+
@package.extract_files @destination
57+
end
58+
59+
assert_path_not_exist File.join(escape_dir, "foo.rb"),
60+
"must not write outside extraction root via symlink"
61+
end
62+
4763
def test_extract_files_security_policy
4864
pend "openssl is missing" unless Gem::HAVE_OPENSSL
4965

0 commit comments

Comments
 (0)