Add fuzz test for adversarial nesting depth#2818
Open
ericproulx wants to merge 1 commit into
Open
Conversation
Several validation-runtime paths recurse on the shape of incoming params: AttributesIterator#do_each recurses into nested arrays, and coercion/oneof walk nested hashes and arrays. A deeply nested payload is a resource- exhaustion surface. This fuzzes random shapes (nested arrays, hashes, mixed) and depths across every recursive path — Array/Hash scopes, type: JSON, oneof, Set coercion — sending real request bytes, and asserts the request is never answered with a 5xx / uncaught exception. It documents that the upstream parsers (Rack param_depth_limit, JSON max_nesting) cap depth well below Grape's own recursion ceiling, so the reachable surface degrades to a controlled 4xx. The generator is seeded from RSpec's seed, so a failure reproduces with --seed <n> and prints the offending endpoint/shape/depth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Danger ReportWarnings
MarkdownsHere's an example of a CHANGELOG.md entry: * [#2818](https://github.com/ruby-grape/grape/pull/2818): Add fuzz test for adversarial nesting depth - [@ericproulx](https://github.com/ericproulx). |
This was referenced Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a fuzz test that hammers Grape's recursive validation paths with randomly-shaped, deeply-nested payloads and asserts the invariant that matters for a resource-exhaustion surface: a request is never answered with a 5xx or an uncaught exception, no matter how it is nested.
This closes the one gap I explicitly flagged as un-covered in the recent validator audit (follow-up to #2814–#2817): the runtime recursion on input shape.
Why nesting depth is a surface
Several validation paths recurse on the structure of the incoming params, not on anything Grape controls:
AttributesIterator#do_eachrecurses into nested arrays.deep_symbolize_keys) andoneof(deep_dup) walk nested hashes/arrays.I measured Grape's intrinsic ceiling by driving
AttributesIterator#do_eachdirectly with a nested Ruby array: it raisesSystemStackErrorat roughly 2000–3000 levels.SystemStackErroris not aStandardError, so nothing inrun_validatorswould catch it.Why it isn't reachable over HTTP (what the test documents)
The upstream parsers cap depth far below that ceiling, so a real request degrades to a controlled 4xx long before Grape's recursion is stressed:
Rack::Utils.param_depth_limit(32)JSON.parsemax_nesting(100)I verified end-to-end over raw request bytes that every recursive endpoint (
type: Array/Hashscopes,type: JSON,oneof,Setcoercion) returns a 4xx at depths of 100, 500, and 2000 — never a 5xx. So the intrinsic ceiling is latent / defense-in-depth, not a live DoS. The fuzz test pins that guarantee in place: if a future change lifted a parser cap or introduced unbounded recursion at a reachable depth, it would fail.What the test does
1..400(spanning both sides of the parser caps — depths under ~100 genuinely reach and exercise Grape's recursion).< 500.RSpec.configuration.seed, so a failure reproduces deterministically with--seed <n>and the message prints the offendingendpoint / shape / depth / leaf.I confirmed the test has teeth by temporarily injecting a crash into
do_eachat a shallow, reachable depth — the fuzz loop and anchored cases both caught it.Notes
🤖 Generated with Claude Code