Reject params nested deeper than the array scope declares - #2838
Open
ericproulx wants to merge 1 commit into
Open
Reject params nested deeper than the array scope declares#2838ericproulx wants to merge 1 commit into
ericproulx wants to merge 1 commit into
Conversation
ericproulx
force-pushed
the
fix/nested-array-validation-bypass
branch
from
July 29, 2026 20:53
0238fd0 to
b03b7a7
Compare
Danger ReportNo issues found. |
ericproulx
force-pushed
the
fix/nested-array-validation-bypass
branch
from
August 1, 2026 11:13
b03b7a7 to
031dd8c
Compare
`AttributesIterator#do_each` recursed into any element that was an Array.
That recursion is required when the declaration itself nests array scopes --
`map_params` adds one level of nesting per element-iterating scope on the
chain, so the params for the inner scope really are an array of arrays -- but
nothing compared the incoming depth against the declared one.
A request could therefore wrap its elements in extra arrays and have them
silently unwrapped:
params do
requires :lines, type: Array do
requires :book_id, type: String
requires :qty, type: Integer
end
end
`{"lines":[[{"book_id":"x","qty":1}]]}` passed validation, and `params[:lines]`
/ `declared` then handed the endpoint `[[{...}]]`. Any ordinary body assuming
the declared shape (`params[:lines].sum { |l| l[:qty] }`) died with a
`TypeError`, i.e. a 500 on malformed input. `[[]]` passed the same way.
Each scope now records, at definition time, how many element-iterating scopes
sit on its chain; the iterator descends that many levels less the one
`Array.wrap` already consumes, and yields anything deeper as-is. The attribute
validators then see a non-hash and fail it exactly as they do for any other
unexpected element type, so these requests get the 400 they always should
have.
"Element-iterating" is a scope predicate rather than an `== Array` test,
because `type: Array[JSON]` iterates elements just as much but evaluates to
the Array *instance* `[JSON]` rather than the Array class. Excluding it also
cost `Array[JSON]` its error indices: every element reported under the same
bracket-less name, so `docs[1][name] is missing` came out as
`docs[name] is missing`, and two failing elements deduped into one message.
Sharing the predicate fixes that too -- `Array[JSON]` now reports per element
exactly as `type: Array do` does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ericproulx
force-pushed
the
fix/nested-array-validation-bypass
branch
from
August 1, 2026 11:35
031dd8c to
adeb549
Compare
4 tasks
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
AttributesIterator#do_eachrecursed into any element that was anArray. That recursion is genuinely required when the declaration nests array scopes —map_paramsadds one level of nesting per element-iterating scope on the chain, so the params for the inner scope really are an array of arrays — but nothing compared the incoming depth against the declared one.A request could therefore wrap its elements in extra arrays and have them silently unwrapped:
{"lines":[{"book_id":"x","qty":1}]}{"lines":[[{"book_id":"x","qty":1}]]}TypeErrorin the endpoint{"lines":[[[{"book_id":"x","qty":1}]]]}TypeError{"lines":[[]]}TypeErrorValidation passed and
params[:lines]/declaredhanded the endpoint[[{...}]], so any ordinary body assuming the declared shape died withTypeError: no implicit conversion of Symbol into Integer— a 500 on malformed input. The same held for an array scope declared inside a hash.type: Array[JSON]error indicesThe scope predicate this needs turned out to be the same one that decides whether an element index is meaningful, and it was wrong for
Array[JSON]— which iterates elements just as much, but evaluates to the Array instance[JSON]rather than theArrayclass, so an== Arraytest quietly excluded it. Every element then reported under the same bracket-less name:requires :docs, type: Array[JSON] do requires :name enddocs[name] is missingdocs[1][name] is missingdocs[name] is missing(one message)docs[0][name] is missing, docs[1][name] is missingSo a client could not tell which element failed, or even how many did — the errors deduped.
type: Array doalready reported correctly;Array[JSON]now matches it.Approach
Each scope records at definition time how many element-iterating scopes sit on its chain (
ParamsScope#array_depth, built on a new#iterates_elements?). The iterator descends that many levels, less the oneArray.wrapalready consumes in#each, and yields anything deeper as-is. The attribute validators then see a non-hash and fail it exactly as they do for any other unexpected element type (hash_like?isrespond_to?(:key?), which anArrayis not), so these requests get the 400 they always should have — no new error path.Declared nesting is unaffected:
requires :a, type: Array do requires :b, type: Array do ... end endstill descends both levels and still reportsa[0][b][0][c] is missing.Backward compatibility
Requests previously accepted through the silent unwrap are now rejected with 400. In practice they could not be served — the endpoint received a shape it never declared — so this converts a 500 into the correct 4xx rather than removing working behaviour.
One error-message change:
Array[JSON]failures now carry the element index.coerce_validator_spec.rb's "accepts Array[JSON] shorthand" asserted the bracket-less form and is updated; a bare object is coerced into a one-element array, so it now reportssplines[0][x]consistently with an explicit array.Neither is a regression — both behaviours are present in 3.3.4 and earlier.
Test plan
params_scope_spec.rbcovering the flat, hash-wrapped and declared-nested cases, plus three incoerce_validator_spec.rbforArray[JSON]indices and multi-element reporting; verified they fail without thelib/change.🤖 Generated with Claude Code