Reject invalid databinding indexes (continues #15804) - #16058
Reject invalid databinding indexes (continues #15804)#16058sanjana2505006 wants to merge 5 commits into
Conversation
Report binding errors for malformed or negative indexed binding paths before array, collection, or domain association access. Apply the same guard to the web data binder association path and cover the core and web binding behavior with regressions. Assisted-by: Hephaestus:openai/gpt-5.5
Document that indexed binding to arrays, collections, and many-ended associations requires non-negative integer indexes, that invalid indexes are reported as binding errors without mutating the target, and that map keys are unaffected. Align the Set binding example comment with the integer index requirement. Assisted-by: opencode:gpt-5.5
Keep Set association keys as grouping keys, cite JavaBeans §7.2 for positional index rejection, and cover non-numeric Set updates by id. Co-authored-by: James Fredley <jamesfredley@users.noreply.github.com>
|
Hi @sanjana2505006 is the intent to replace @jamesfredley 's PR? |
This comment has been minimized.
This comment has been minimized.
|
@jdaugherty since this includes my commits, yes this will replace it. External contributors can't commit to our origin branches. This could alternatively targeted the other PR's branch, but either is fine. |
jdaugherty
left a comment
There was a problem hiding this comment.
The main issues I have with this is the lack of warning in the upgrade guide & the big one revolves around set. It looks like Set is no longer supported, but Set mirrors a map more than list. I'm ok with the negative values not being supported in a Set, but Set seems valid to me and this breaks that support. I had AI do the review and tweaked the output, that review is what follows.
Thanks for picking this up. Three of the four points I raised on #15804 are genuinely resolved: the groovydoc now cites JavaBeans v1.01 §7.2, the Set update-by-id path no longer parses the grouping key, and the guide states the rationale instead of presenting the restriction as an arbitrary rule. I verified the Set fix behaves as intended — authors[foo]: [id: <existing>, ...] and even authors[-1]: [id: <existing>, ...] now bind cleanly.
Two of the four are only partially addressed, and both are inline below:
-
The
Setfix stops atGrailsWebDataBinder.SimpleDataBinder.processIndexedPropertystill parses and requires a non-negative integer for everyCollection,Setincluded — so within the sameSetassociation the grouping key is arbitrary when the map carries anidand mandatory-integer when it does not. I confirmed this:authors[foo]: [name: 'Brand New Author']produces a binding error and adds nothing, whileauthors[0]: [name: 'Brand New Author']binds. Same for a plainSet<String>property:setOfCodes[foo]is rejected. The parsed index is provably dead forSets, so this restriction buys nothing. -
The new code comment asserts a property the code does not have. The negative and malformed paths do not produce the same error —
NumberFormatExceptionmessages are-1andFor input string: "bad"respectively, andGrailsWebDataBindingListener(line 55) copieserror.cause?.messageinto theFieldErrordefaultMessage. An inaccurate comment is worse than none, because the next reader will trust it.
Missing what's new / upgrade notice. This changes how request parameters bind in an existing application — books[-1].title previously mutated the last element via Groovy's putAt(-1) and now reports a binding error. That is exactly the class of change the release docs need to carry, and neither grails-doc/src/en/guide/introduction/whatsNew.adoc nor grails-doc/src/en/guide/upgrading/upgrading80x.adoc mentions it. Please add:
- a
====subsection towhatsNew.adocdescribing the hardening (indexed array/collection/positional-association binding now requires non-negative integer indexes; invalid paths become binding errors rather than throwing or silently selecting a different element), and - a corresponding entry in
upgrading80x.adoc— either a bullet under==== 20. Other Default Behavior Changesor its own numbered section — telling upgraders what to look for in existing form markup and request parameters.
The §7.2 reasoning belongs in both, since it is what makes this an alignment with the beans model rather than a feature removal.
Housekeeping. The squashed commit message should not carry the second commit's "Align the Set binding example comment with the integer index requirement" line — the third commit reverses exactly that, so the claim would land in history inverted. Also, #15804 is still open; if this supersedes it, that one should be closed so the two do not drift.
| } | ||
| } else if (Collection.isAssignableFrom(propertyType)) { | ||
| def index = Integer.parseInt(indexedPropertyReferenceDescriptor.index) | ||
| Integer index = parseIndexedPropertyIndex(obj, indexedPropertyReferenceDescriptor, val, listener, errors) |
There was a problem hiding this comment.
This is the other half of the Set issue I raised on #15804, and it is still open. Set reaches this branch (Collection.isAssignableFrom(Set) is true), so a Set-typed property still requires a non-negative integer key here — even though the parsed index is dead for Sets: isOkToAddElementAt ignores index when the collection is a Set (it only checks collection.size() < autoGrowCollectionLimit), and addElementToCollectionAt then calls collection.add(val). Nothing downstream consumes the value.
The consequence is that the fix in GrailsWebDataBinder only covers the update-existing-by-id path. When the map has no id, getIdentifierValueFrom(val) returns null, needsBinding stays true, and the call falls through to this branch. So the same association accepts an arbitrary grouping key for an update and rejects it for an insert:
authors[foo]: [id: <existing>, name: 'Renamed'] -> binds (your fix)
authors[foo]: [name: 'Brand New Author'] -> binding error, nothing added
authors[0]: [name: 'Brand New Author'] -> binds
A plain non-domain Set is rejected outright: setOfCodes[foo] yields a binding error and leaves the property null.
Please move the parse inside the non-Set path so the index is only required where it is actually used, e.g.:
} else if (Collection.isAssignableFrom(propertyType)) {
boolean isSet = Set.isAssignableFrom(propertyType)
Integer index = 0
if (!isSet) {
index = parseIndexedPropertyIndex(obj, indexedPropertyReferenceDescriptor, val, listener, errors)
if (index == null) {
return
}
}
Collection collectionInstance = initializeCollection(obj, propName, propertyType)
def indexedInstance = null
if (!isSet) {
indexedInstance = collectionInstance[index]
}That makes the core binder agree with the Set branch in GrailsWebDataBinder and with the guide text this PR adds.
| // Intentional: report the same generic NumberFormatException as a | ||
| // malformed index so untrusted request data cannot distinguish that | ||
| // negative indexes are handled explicitly. | ||
| throw new NumberFormatException(indexedPropertyReferenceDescriptor.index) |
There was a problem hiding this comment.
The comment is the right idea but the code does not deliver what it claims, so as written it will mislead a future reader into leaving a real gap in place.
Integer.parseInt builds its own message, and this throw supplies a different one, so the two rejections are distinguishable by message:
listOfCodes[-1] -> NumberFormatException, message: -1
listOfCodes[bad] -> NumberFormatException, message: For input string: "bad"
That difference is not internal: GrailsWebDataBindingListener line 55 does def defaultMessage = error.cause?.message ?: 'Data Binding Failed', so the message becomes the FieldError's defaultMessage and is rendered whenever no message code resolves. The shape of the message therefore reveals that the index parsed successfully and was rejected by a separate check. As a side effect, a rejected [-1] currently surfaces to the user as the bare text -1, which is not a usable error message either.
Construct the exception identically on both paths rather than inheriting parseInt's message — then the comment's guarantee actually holds:
protected Integer parseIndexedPropertyIndex(obj, IndexedPropertyReferenceDescriptor indexedPropertyReferenceDescriptor,
val, DataBindingListener listener, errors) {
String rawIndex = indexedPropertyReferenceDescriptor.index
Integer index = null
try {
index = Integer.parseInt(rawIndex)
}
catch (NumberFormatException ignored) {
// handled below, with the same error as a negative index
}
if (index == null || index < 0) {
// Intentional: malformed and negative indexes are reported identically, so
// untrusted request data cannot tell that negative indexes are handled by a
// separate check. Do not "improve" this into two distinct messages.
addBindingError(obj, indexedPropertyReferenceDescriptor.toString(), val,
new NumberFormatException("For input string: \"${rawIndex}\""), listener, errors)
return null
}
index
}This also drops the throw-as-control-flow, which is what made the original shape fragile.
| addBindingError(obj, propName, idValue, e, listener, errors) | ||
| } else { | ||
| addElementToCollectionAt(obj, propName, collection, Integer.parseInt(indexedPropertyReferenceDescriptor.index), instance) | ||
| addElementToCollectionAt(obj, propName, collection, 0, instance) |
There was a problem hiding this comment.
The behavior is right, but a bare 0 in an argument position named index reads as "add at the front" and invites someone to "fix" it back to the parsed key. Worth stating why any value is inert here: this branch is guarded by Set.isAssignableFrom(metaProperty.type), and for a Set addElementToCollectionAt never uses the index — isOkToAddElementAt only checks collection.size() < autoGrowCollectionLimit and the add is collection.add(val).
Folding that into the comment you already added at the top of the branch would be enough, e.g. "...Selection is by id, and addElementToCollectionAt ignores the index for Sets, so the value passed below is inert."
|
|
||
| That code would work in the same way if `albums` were an array instead of a `List`. | ||
|
|
||
| NOTE: Array and positional collection binding follow the JavaBeans indexed-property model (spec v1.01, section 7.2), which only defines non-negative `int` indexes with array semantics. Entries such as `albums[-1]` or `albums[bogus]` are rejected as binding errors. The error field name includes the offending indexed segment, that binding path is skipped, and the target array, collection, or association is not changed by that entry. The JavaBeans specification does not cover `Set` binding; `Set` association keys remain arbitrary grouping keys (see below). Map keys are not interpreted as numeric indexes, so keys such as `players[guitar]` remain valid map keys. |
There was a problem hiding this comment.
This is much better than the previous wording, and the map-key sentence is accurate — I confirmed players[guitar] and even a literal -1 map key still bind as keys.
But "Set association keys remain arbitrary grouping keys" overstates what the code does today, in two ways:
- It is only true on the update-existing-by-id path. Adding a new element with no
idfalls through toSimpleDataBinder, which still requires a non-negative integer — see my comment on theCollectionbranch there. - It is scoped to association keys, but a reader will reasonably apply it to the generic
Setparagraph immediately below. A plainSet<String>property is not an association and is rejected:setOfCodes[foo]produces a binding error.
If you take the SimpleDataBinder fix, this sentence becomes true as written and nothing here needs to change. If you would rather keep the current behavior, then this sentence has to say that arbitrary keys apply only when updating an existing element of a Set association by id — which is a contract worth avoiding, so I would prefer the code fix.
| updatedA3.name == 'Author Tres' | ||
| } | ||
|
|
||
| void 'Test updating Set elements by id with non-numeric grouping keys'() { |
There was a problem hiding this comment.
Good test, and it does pin the regression I flagged. Two gaps that matter for keeping it pinned:
-
Nothing covers a negative key on the
Setpath.authors[-1]: [id: <existing>, ...]is accepted today (I verified: it binds, no error), and that acceptance is deliberate — aSetkey is not a position. But in a PR whose entire purpose is rejecting[-1], an untested acceptance is the first thing a future "consistency" cleanup will remove. Please add a case assertingauthors[-1]with anidstill binds and raises no binding error. -
Nothing covers adding a new element to a
Setwith a non-numeric key. That case currently fails (authors[foo]: [name: 'Brand New Author']-> binding error, nothing added). Once theSimpleDataBinderbranch is fixed it should bind, so a test here would both drive that fix and lock it in.
A plain non-domain Set case belongs in CollectionBindingSpec alongside the new List/array tests, for the same reason.
|
|
||
| List<BindingError> bindingErrors = [] | ||
|
|
||
| void bindingError(BindingError error, errors) { |
There was a problem hiding this comment.
Missing @Override. DataBindingListenerAdapter.bindingError(BindingError, Object) is a Java method, so this compiles either way, but a typo in the name or arity would silently stop collecting errors and the assertions would pass against an always-empty list. The equivalent listener added in GrailsWebDataBinderSpec in this same PR does annotate it — worth matching:
@Override
void bindingError(BindingError error, errors) {
bindingErrors << error
}Also make bad/negative index errors look the same, and add the missing tests and upgrade notes from review.
|
Addressed the review in |
Continues #15804.
Built on James's work and addressed the open review comments from @jdaugherty.
Main ones:
authors[foo]style Set updatesRan CollectionBindingSpec, GrailsWebDataBinderSpec, and :grails-web-databinding:check.