CVE-2023-40165 (Ruby, published 2023-08-17T18:15Z)
Description: value: rubygems.org is the Ruby community's primary gem (library) hosting service. Insufficient input validation allowed malicious actors to replace any uploaded gem version that had a platform, version number, or gem name matching `/-\d/`, permanently replacing the legitimate upload in the canonical gem storage bucket, and triggering an immediate CDN purge so that the malicious gem would be served immediately. The maintainers have checked all gems matching the `/-\d/` pattern and can confirm that no unexpected `.gem`s were found. As a result, we believe this vulnerability was _not_ exploited. The easiest way to ensure that a user's applications were not exploited by this vulnerability is to check that all of your downloaded .gems have a checksum that matches the checksum recorded in the RubyGems.org database. RubyGems contributor Maciej Mensfeld wrote a tool to automatically check that all downloaded .gem files match the checksums recorded in the RubyGems.org database. You can use it by running: `bundle add bundler-integrity` followed by `bundle exec bundler-integrity`. Neither this tool nor anything else can prove you were not exploited, but the can assist your investigation by quickly comparing RubyGems API-provided checksums with the checksums of files on your disk. The issue has been patched with improved input validation and the changes are live. No action is required on the part of the user. Users are advised to validate their local gems.
Severity: nan (CVSS v3 base score: 7.5)
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/rubygems/rubygems.org
Files changed:
Gemfile
Gemfile.lock
app/models/pusher.rb
app/models/specification_policy.rb
app/models/version.rb
lib/patterns.rb
test/helpers/gem_helpers.rb
test/integration/push_test.rb
test/models/pusher_test.rb
Commit message: Merge pull request from GHSA-rxcq-2m4f-94wm

* Add improved input validation of pushed gemspecs

See https://github.com/rubygems/rubygems.org/security/advisories/GHSA-rxcq-2m4f-94wm

* Remove debugging code

Vulnerable code:
    signature_missing = "There was a problem saving your gem: \nYou have added cert_chain in gemspec but signature was empty"
    return notify(signature_missing, 403) unless validate_signature_exists?
    (rubygem.valid? && version.valid?) || notify("There was a problem saving your gem: #{rubygem.all_errors(version)}", 403)
    original_name = spec.original_name
  validates :required_rubygems_version, :licenses, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, allow_blank: true
  NAME_PATTERN          = /\A#{ALLOWED_CHARACTERS}\Z/
    build_gem "sandworm", "1.0.0" do |spec|
      spec.instance_variable_set :@authors, "string"
    end

Fixed code:
  gem "aggregate_assertions", "~> 0.2.0"
    aggregate_assertions (0.2.0)
      minitest (~> 5.0)
  aggregate_assertions (~> 0.2.0)
    unless validate_signature_exists?
      return notify("There was a problem saving your gem: \nYou have added cert_chain in gemspec but signature was empty", 403)
    end

    return notify("There was a problem saving your gem: #{rubygem.all_errors(version)}", 403) unless rubygem.valid? && version.valid?

    unless version.full_name == spec.original_name && Patterns::NAME_PATTERN.match?(spec.platform.to_s)
      return notify("There was a problem saving your gem: the uploaded spec has malformed platform attributes", 409)
    end
    true
    validate_spec
    # we validate that the version full_name == spec.original_name
    original_name = @version.full_name

  def validate_spec
    @spec.send(:invalidate_memoized_attributes)

    spec = @spec.dup

    cert_chain = spec.cert_chain

    spec.abbreviate
    spec.sanitize

    # make sure we validate the cert chain, which gets snipped in abbreviate
    spec.cert_chain = cert_chain

    # Silence warnings from the verification
    stream = StringIO.new
    policy = SpecificationPolicy.new(spec)
    policy.ui = Gem::StreamUI.new(stream, stream, stream, false)
    policy.validate(false)
  end
class SpecificationPolicy < Gem::SpecificationPolicy
  def error(statement)
    return if statement.start_with?("#{Gem::SpecificationPolicy::LAZY} is not a")

    super
  end

  def warning(statement)
    # do nothing
  end
end
  validates :required_rubygems_version, :licenses, :required_ruby_version, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, allow_blank: true
  NAME_PATTERN          = /\A#{ALLOWED_CHARACTERS}\z/
  def build_gem_raw(file_name:, spec:, contents_writer: nil)
    package = Gem::Package.new file_name

    File.open(file_name, "wb") do |file|
      Gem::Package::TarWriter.new(file) do |gem|
        gem.add_file "metadata.gz", 0o444 do |io|
          package.gzip_to(io) do |gz_io|
            gz_io.write spec
          end
        end
        gem.add_file "data.tar.gz", 0o444 do |io|
          package.gzip_to io do |gz_io|
            Gem::Package::TarWriter.new gz_io do |data_tar|
              contents_writer[data_tar] if contents_writer
            end
          end
        end
      end
    end
  end

    assert_response :success

    assert_response :success

    assert_response :success

    build_gem "sandworm", "1.0.0"

    # Error on empty authors now happens in a different place,
    # but test what would happen if marshal dumping failed
    Gem::Specification.any_instance.stubs(:_dump).raises(NoMethodError)
  context "with specially crafted gemspecs" do
    should "not allow overwriting gem with -\\d in name" do
      create(:version, number: "2.0", rubygem: create(:rubygem, name: "book-2"))

      build_gem_raw(file_name: "malicious.gem", spec: <<~YAML)
        --- !ruby/hash-with-ivars:Gem::Specification
        ivars:
          '@name': book
          '@version': '2-2.0'
          '@platform': 'not_ruby'
          '@original_platform': 'not-ruby'
          '@new_platform': ruby
          '@summary': 'malicious'
          '@authors': [test@example.com]
      YAML

      push_gem "malicious.gem"

      aggregate_assertions "should fail to push" do
        assert_response :conflict

        assert_nil Rubygem.find_by(name: "book")
        assert_nil RubygemFs.instance.get("gems/book-2-2.0.gem")
        assert_nil RubygemFs.instance.get("quick/Marshal.4.8/book-2-2.0.gemspec.rz")
      end
    end

    should "not allow overwriting platform gem" do
      create(:version, number: "2.0", platform: "universal-darwin-19", rubygem: create(:rubygem, name: "book"))

      build_gem_raw(file_name: "malicious.gem", spec: <<~YAML)
        --- !ruby/hash-with-ivars:Gem::Specification
        ivars:
          '@name': book-2.0-universal-darwin
          '@version': '19'
          '@platform': 'not_ruby'
          '@original_platform': 'not-ruby'
          '@new_platform': ruby
          '@summary': 'malicious'
          '@authors': [test@example.com]
      YAML

      push_gem "malicious.gem"

      aggregate_assertions "should fail to push" do
        assert_response :conflict

        assert_nil Rubygem.find_by(name: "book-2.0-universal-darwin")
        assert_nil RubygemFs.instance.get("gems/book-2.0-universal-darwin-19.gem")
        assert_nil RubygemFs.instance.get("quick/Marshal.4.8/book-2.0-universal-darwin-19.gemspec.rz")
      end
    end

    context "does not allow pushing a gem where the file name does not match the version full_name" do
      should "fail when original platform is a ruby Gem::Platform" do
        build_gem_raw(file_name: "malicious.gem", spec: <<~YAML)
          --- !ruby/object:Gem::Specification
          specification_version: 100
          name: book
          version: '1'
          platform: !ruby/object:Gem::Platform
            os: ruby
          summary: 'malicious'
          authors: [test@example.com]
        YAML
        push_gem "malicious.gem"

        aggregate_assertions "should fail to push" do
          assert_response :conflict

          assert_nil Rubygem.find_by(name: "book")
          assert_nil RubygemFs.instance.get("gems/book-1-ruby.gem")
          assert_nil RubygemFs.instance.get("quick/Marshal.4.8/book-1-ruby.gemspec.rz")
        end
      end

      should "fail when original platform is an array that resolves to a platform of ruby" do
        build_gem_raw(file_name: "malicious.gem", spec: <<~YAML)
          --- !ruby/object:Gem::Specification
          specification_version: 100
          name: book
          version: '1'
          platform: [ruby]
          summary: 'malicious'
          authors: [test@example.com]
        YAML
        push_gem "malicious.gem"

        assert_response :forbidden
      end
    end

    should "fail fast when spec.name is not a string" do
      build_gem_raw(file_name: "malicious.gem", spec: <<~YAML)
        --- !ruby/object:Gem::Specification
        name: !ruby/object:Gem::Version
          version: []
        version: '1'
        summary: 'malicious'
        authors: [test@example.com]
      YAML
      push_gem "malicious.gem"

      assert_response :unprocessable_entity
    end

    should "fail when spec.platform is invalid" do
      build_gem_raw(file_name: "malicious.gem", spec: <<~YAML)
        --- !ruby/hash-with-ivars:Gem::Specification
        ivars:
          '@name': book
          '@version': '1'
          '@new_platform': !ruby/object:Gem::Platform
            os: "../../../../../etc/passwd"
          '@original_platform': ruby
          '@summary': 'malicious'
          '@authors': [test@example.com]
      YAML
      push_gem "malicious.gem"

      assert_response :conflict
    end
  end


  make_my_diffs_pretty!
      # this isn't the kind of invalid that we're testing with this gem
      Gem::Specification.any_instance.stubs(:authors).returns(["user@example.com"])