Skip to content

Commit

Permalink
Check safety of packaged symlinks
Browse files Browse the repository at this point in the history
If we explicitly disallow the creation of symlinks that point to files
outside of the destination directory, we can avoid any other safety
checks while creating directories, because we can be sure they will
always fall under the destination directory as well.
  • Loading branch information
deivid-rodriguez committed Oct 8, 2021
1 parent 0a0ad34 commit 555692b
Show file tree
Hide file tree
Showing 2 changed files with 23 additions and 21 deletions.
33 changes: 16 additions & 17 deletions lib/rubygems/package.rb
Expand Up @@ -71,6 +71,13 @@ def initialize(destination, destination_dir)
end
end

class SymlinkError < Error
def initialize(name, destination, destination_dir)
super "installing symlink '%s' pointing to parent path %s of %s is not allowed" %
[name, destination, destination_dir]
end
end

class NonSeekableIO < Error; end

class TooLongFileName < Error; end
Expand Down Expand Up @@ -407,6 +414,14 @@ def extract_tar_gz(io, destination_dir, pattern = "*") # :nodoc:

destination = install_location entry.full_name, destination_dir

if entry.symlink?
link_target = entry.header.linkname
real_destination = link_target.start_with?("/") ? link_target : File.expand_path(link_target, File.dirname(destination))

raise Gem::Package::SymlinkError.new(entry.full_name, real_destination, destination_dir) unless
normalize_path(real_destination).start_with? normalize_path(destination_dir + '/')
end

FileUtils.rm_rf destination

mkdir_options = {}
Expand All @@ -419,7 +434,7 @@ def extract_tar_gz(io, destination_dir, pattern = "*") # :nodoc:
end

unless directories.include?(mkdir)
mkdir_p_safe mkdir, mkdir_options, destination_dir, entry.full_name
FileUtils.mkdir_p mkdir, **mkdir_options
directories << mkdir
end

Expand Down Expand Up @@ -495,22 +510,6 @@ def normalize_path(pathname)
end
end

def mkdir_p_safe(mkdir, mkdir_options, destination_dir, file_name)
destination_dir = File.realpath(File.expand_path(destination_dir))
parts = mkdir.split(File::SEPARATOR)
parts.reduce do |path, basename|
path = File.realpath(path) unless path == ""
path = File.expand_path(path + File::SEPARATOR + basename)
lstat = File.lstat path rescue nil
if !lstat || !lstat.directory?
unless normalize_path(path).start_with? normalize_path(destination_dir) and (FileUtils.mkdir path, **mkdir_options rescue false)
raise Gem::Package::PathError.new(file_name, destination_dir)
end
end
path
end
end

##
# Loads a Gem::Specification from the TarEntry +entry+

Expand Down
11 changes: 7 additions & 4 deletions test/rubygems/test_gem_package.rb
Expand Up @@ -574,18 +574,19 @@ def test_extract_symlink_parent
destination_subdir = File.join @destination, 'subdir'
FileUtils.mkdir_p destination_subdir

expected_exceptions = win_platform? ? [Gem::Package::PathError, Errno::EACCES] : [Gem::Package::PathError]
expected_exceptions = win_platform? ? [Gem::Package::SymlinkError, Errno::EACCES] : [Gem::Package::SymlinkError]

e = assert_raise(*expected_exceptions) do
package.extract_tar_gz tgz_io, destination_subdir
end

pend "symlink - must be admin with no UAC on Windows" if Errno::EACCES === e

assert_equal("installing into parent path lib/link/outside.txt of " +
assert_equal("installing symlink 'lib/link' pointing to parent path #{@destination} of " +
"#{destination_subdir} is not allowed", e.message)

assert_path_not_exist File.join(@destination, "outside.txt")
assert_path_not_exist File.join(destination_subdir, "lib/link")
end

def test_extract_symlink_parent_doesnt_delete_user_dir
Expand All @@ -608,18 +609,20 @@ def test_extract_symlink_parent_doesnt_delete_user_dir
tar.add_symlink 'link/dir', '.', 16877
end

expected_exceptions = win_platform? ? [Gem::Package::PathError, Errno::EACCES] : [Gem::Package::PathError]
expected_exceptions = win_platform? ? [Gem::Package::SymlinkError, Errno::EACCES] : [Gem::Package::SymlinkError]

e = assert_raise(*expected_exceptions) do
package.extract_tar_gz tgz_io, destination_subdir
end

pend "symlink - must be admin with no UAC on Windows" if Errno::EACCES === e

assert_equal("installing into parent path #{destination_user_subdir} of " +
assert_equal("installing symlink 'link' pointing to parent path #{destination_user_dir} of " +
"#{destination_subdir} is not allowed", e.message)

assert_path_exist destination_user_subdir
assert_path_not_exist File.join(destination_subdir, "link/dir")
assert_path_not_exist File.join(destination_subdir, "link")
end

def test_extract_tar_gz_directory
Expand Down

0 comments on commit 555692b

Please sign in to comment.