From 56ed326cb21d6646ecdb7a32fdf3a17ca1dd0347 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 14 Jul 2026 17:27:53 +0900 Subject: [PATCH] Validate spec name before writing to the spec cache Gem::Source#fetch_spec built the local spec cache path directly from the name tuple returned by a remote index, which is never validated for use as a path component. A crafted gem name containing path separators or `..` could therefore make fetch_spec write the downloaded gemspec bytes outside Gem.spec_cache_dir. Reject any spec name that is not a plain basename before constructing the cache path. Co-Authored-By: Claude --- lib/rubygems/source.rb | 7 +++++++ test/rubygems/test_gem_source.rb | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/lib/rubygems/source.rb b/lib/rubygems/source.rb index 348bf6a24bc0..b01619371efc 100644 --- a/lib/rubygems/source.rb +++ b/lib/rubygems/source.rb @@ -109,6 +109,13 @@ def fetch_spec(name_tuple) spec_file_name = name_tuple.spec_name + # The name tuple comes from a remote index and is not otherwise + # validated, so refuse anything that would escape the spec cache + # directory when used as a path component. + if File.basename(spec_file_name) != spec_file_name + raise Gem::Exception, "malformed spec name: #{spec_file_name.inspect}" + end + source_uri = enforce_trailing_slash(uri) + "#{Gem::MARSHAL_SPEC_DIR}#{spec_file_name}" cache_dir = cache_dir source_uri diff --git a/test/rubygems/test_gem_source.rb b/test/rubygems/test_gem_source.rb index a0bc10560df5..a030b58851c7 100644 --- a/test/rubygems/test_gem_source.rb +++ b/test/rubygems/test_gem_source.rb @@ -121,6 +121,19 @@ def test_fetch_spec_platform_ruby assert_equal @specs["a-1"].full_name, spec.full_name end + def test_fetch_spec_path_traversal + escape = File.expand_path(File.join(Gem.spec_cache_dir, "..", "owned.gemspec")) + + name_tuple = tuple("../owned", Gem::Version.new(1), "ruby") + + e = assert_raise Gem::Exception do + @source.fetch_spec name_tuple + end + + assert_includes e.message, "malformed spec name" + refute File.exist?(escape), "spec must not be written outside the spec cache" + end + def test_load_specs released = @source.load_specs(:released).map(&:full_name) assert_equal %W[a-2 a-1 b-2], released