Skip to content

Cap nesting depth in Gem::SafeMarshal::Reader - #9870

Closed
davutselcuk wants to merge 1 commit into
ruby:masterfrom
davutselcuk:safe-marshal-nesting-cap
Closed

Cap nesting depth in Gem::SafeMarshal::Reader#9870
davutselcuk wants to merge 1 commit into
ruby:masterfrom
davutselcuk:safe-marshal-nesting-cap

Conversation

@davutselcuk

Copy link
Copy Markdown

Opened at your request, from the closing comment on HackerOne #4013093:

Adding a nesting cap to Gem::SafeMarshal::Reader symmetric with the 1000-level cap already in yaml_serializer.rb is worthwhile hardening, so please open it as a pull request.

What this does

Reader#read_element recurses once per nested element and counts nothing, so a stream of nested arrays runs the stack down before any other limit applies. Marshal spends two bytes per level.

This adds the symmetric cap:

MAX_NESTING_DEPTH = 1_000

Past it the reader raises Reader::TooDeeplyNestedError, which is a Reader::Error and therefore a StandardError. That matters for callers already written to tolerate a bad spec, such as Gem::Source#fetch_spec:

spec = begin
         Gem::SafeMarshal.safe_load(spec)
       rescue StandardError
         nil
       end

A SystemStackError descends from Exception, so that rescue never sees it and the intended "skip the bad spec" path does not run. With the cap, it does.

Depth, not width

The counter lives in read_element and unwinds in an ensure, so sibling count is unaffected:

  • 998-level nest — parses
  • array of 5,000 siblings — parses
  • 2,400-level nest — TooDeeplyNestedError

case is untouched; the diff is 12 lines of library code.

Tests

Three added, covering the cap, a nest just under it, and the wide-but-shallow case.

test/rubygems/test_gem_safe_marshal.rb   193 tests, 2218 assertions, 0 failures, 0 errors
test/rubygems/test_gem_source.rb          40 tests,  160 assertions, 0 failures, 0 errors

Also checked a real rack-3.1.8 gemspec fetched from rubygems.org still parses.

🤖 Generated with Claude Code

`Reader#read_element` recurses once per nested element and counts nothing,
so a marshal stream of nested arrays exhausts the stack before any other
limit applies. Marshal spends two bytes per level, so ~4.8 KB is enough.

`SystemStackError` descends from `Exception`, not `StandardError`, so it
walks past callers that already guard this parse. `Gem::Source#fetch_spec`
is written to tolerate a bad spec and carry on:

    spec = begin
             Gem::SafeMarshal.safe_load(spec)
           rescue StandardError
             nil
           end

That rescue cannot see a `SystemStackError`, so the intended "ignore the
bad spec" path never runs.

This adds a depth cap symmetric with the one `yaml_serializer.rb` already
applies to the other parser in this tree:

    MAX_NESTING_DEPTH = 1_000

Past the cap the reader raises `Reader::TooDeeplyNestedError`, which is a
`Reader::Error` and therefore a `StandardError`, so existing rescues
behave as written.

Depth is counted per `read_element`, so width is unaffected: an array of
5,000 siblings still parses, and so does a 998-level nest.

Tests cover the cap, a nest just under it, and the wide-but-shallow case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@davutselcuk davutselcuk closed this by deleting the head repository Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant