-
-
Notifications
You must be signed in to change notification settings - Fork 973
Reject invalid databinding indexes (continues #15804) #16058
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 8.0.x
Are you sure you want to change the base?
Changes from 3 commits
84c8020
07a6bc1
f6e0609
5c6c833
4d6560e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -333,13 +333,19 @@ class SimpleDataBinder implements DataBinder { | |
| } | ||
|
|
||
| if (propertyType.isArray()) { | ||
| def index = Integer.parseInt(indexedPropertyReferenceDescriptor.index) | ||
| Integer index = parseIndexedPropertyIndex(obj, indexedPropertyReferenceDescriptor, val, listener, errors) | ||
| if (index == null) { | ||
| return | ||
| } | ||
| def array = initializeArray(obj, propName, propertyType.componentType, index) | ||
| if (array != null) { | ||
| addElementToArrayAt(array, index, val) | ||
| } | ||
| } else if (Collection.isAssignableFrom(propertyType)) { | ||
| def index = Integer.parseInt(indexedPropertyReferenceDescriptor.index) | ||
| Integer index = parseIndexedPropertyIndex(obj, indexedPropertyReferenceDescriptor, val, listener, errors) | ||
| if (index == null) { | ||
| return | ||
| } | ||
| Collection collectionInstance = initializeCollection(obj, propName, propertyType) | ||
| def indexedInstance = null | ||
| if (!(Set.isAssignableFrom(propertyType))) { | ||
|
|
@@ -394,6 +400,37 @@ class SimpleDataBinder implements DataBinder { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Parses an indexed binding path segment as a non-negative integer. | ||
| * <p> | ||
| * Indexed properties follow the JavaBeans model (spec v1.01, section 7.2): | ||
| * array-typed properties with paired {@code int}-indexed accessors. Grails | ||
| * extends that model to positional collection binding; negative indexes are | ||
| * rejected because they are not part of the beans model (the prior | ||
| * {@code [-1]} behavior was Groovy list semantics leaking through the binder). | ||
| * </p> | ||
| * | ||
| * @return the parsed index, or {@code null} when the segment is rejected as a binding error | ||
| */ | ||
| protected Integer parseIndexedPropertyIndex(obj, IndexedPropertyReferenceDescriptor indexedPropertyReferenceDescriptor, | ||
| val, DataBindingListener listener, errors) { | ||
|
|
||
| try { | ||
| Integer index = Integer.parseInt(indexedPropertyReferenceDescriptor.index) | ||
| if (index < 0) { | ||
| // 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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.
That difference is not internal: Construct the exception identically on both paths rather than inheriting 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. |
||
| } | ||
| index | ||
| } | ||
| catch (NumberFormatException e) { | ||
| addBindingError(obj, indexedPropertyReferenceDescriptor.toString(), val, e, listener, errors) | ||
| null | ||
| } | ||
| } | ||
|
|
||
| @CompileStatic(TypeCheckingMode.SKIP) | ||
| protected initializeArray(obj, String propertyName, Class arrayType, int index) { | ||
| Object[] array = obj[propertyName] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,8 +18,8 @@ | |
| */ | ||
| package grails.databinding | ||
|
|
||
| import grails.databinding.SimpleDataBinder; | ||
| import grails.databinding.SimpleMapDataBindingSource; | ||
| import grails.databinding.errors.BindingError | ||
| import grails.databinding.events.DataBindingListenerAdapter | ||
| import spock.lang.Specification | ||
|
|
||
| class CollectionBindingSpec extends Specification { | ||
|
|
@@ -111,6 +111,60 @@ class CollectionBindingSpec extends Specification { | |
| company.departments[2].numberOfEmployees == 99 | ||
| } | ||
|
|
||
| void 'Test negative indexed binding to a List is rejected'() { | ||
| given: | ||
| def binder = new SimpleDataBinder() | ||
| def company = new Company() | ||
| def listener = new CollectionBindingListener() | ||
|
|
||
| when: | ||
| binder.bind company, new SimpleMapDataBindingSource([ | ||
| 'departments[-1]': [name: 'Bad Department'], | ||
| 'departments[0]': [name: 'Department Zero']]), listener | ||
|
|
||
| then: | ||
| company.departments.size() == 1 | ||
| company.departments[0].name == 'Department Zero' | ||
| listener.bindingErrors.size() == 1 | ||
| listener.bindingErrors[0].propertyName == 'departments[-1]' | ||
| listener.bindingErrors[0].rejectedValue == [name: 'Bad Department'] | ||
| } | ||
|
|
||
| void 'Test malformed indexed binding to a List is rejected'() { | ||
| given: | ||
| def binder = new SimpleDataBinder() | ||
| def company = new Company() | ||
| def listener = new CollectionBindingListener() | ||
|
|
||
| when: | ||
| binder.bind company, new SimpleMapDataBindingSource([ | ||
| 'departments[bad]': [name: 'Bad Department'], | ||
| 'departments[0]': [name: 'Department Zero']]), listener | ||
|
|
||
| then: | ||
| company.departments.size() == 1 | ||
| company.departments[0].name == 'Department Zero' | ||
| listener.bindingErrors.size() == 1 | ||
| listener.bindingErrors[0].propertyName == 'departments[bad]' | ||
| listener.bindingErrors[0].rejectedValue == [name: 'Bad Department'] | ||
| } | ||
|
|
||
| void 'Test negative indexed binding to an array is rejected'() { | ||
| given: | ||
| def binder = new SimpleDataBinder() | ||
| def library = new Library() | ||
| def listener = new CollectionBindingListener() | ||
|
|
||
| when: | ||
| binder.bind library, new SimpleMapDataBindingSource(['codes[-1]': 'bad', 'codes[0]': 'good']), listener | ||
|
|
||
| then: | ||
| library.codes as List == ['good'] | ||
| listener.bindingErrors.size() == 1 | ||
| listener.bindingErrors[0].propertyName == 'codes[-1]' | ||
| listener.bindingErrors[0].rejectedValue == 'bad' | ||
| } | ||
|
|
||
| void 'Test binding to an untyped List'() { | ||
| given: | ||
| def binder = new SimpleDataBinder() | ||
|
|
@@ -156,9 +210,22 @@ class Company { | |
| List<Department> departments | ||
| } | ||
|
|
||
| class Library { | ||
| String[] codes | ||
| } | ||
|
|
||
| class Department { | ||
| String name | ||
| Integer numberOfEmployees | ||
| List listOfCodes | ||
| Set setOfCodes | ||
| } | ||
|
|
||
| class CollectionBindingListener extends DataBindingListenerAdapter { | ||
|
|
||
| List<BindingError> bindingErrors = [] | ||
|
|
||
| void bindingError(BindingError error, errors) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing @Override
void bindingError(BindingError error, errors) {
bindingErrors << error
} |
||
| bindingErrors << error | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -128,13 +128,15 @@ assert band.albums[1].numberOfTracks == 7 | |
|
|
||
| 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is much better than the previous wording, and the map-key sentence is accurate — I confirmed But "
If you take the |
||
|
|
||
| Note that when binding to a `Set` the structure of the `Map` being bound to the `Set` is the same as that of a `Map` being bound to a `List` but since a `Set` is unordered, the indexes don't necessarily correspond to the order of elements in the `Set`. In the code example above, if `albums` were a `Set` instead of a `List`, the `bindingMap` could look exactly the same but 'Foxtrot' might be the first album in the `Set` or it might be the second. When updating existing elements in a `Set` the `Map` being assigned to the `Set` must have `id` elements in it which represent the element in the `Set` being updated, as in the following example: | ||
|
|
||
| [source,groovy] | ||
| ---- | ||
| /* | ||
| * The value of the indexes 0 and 1 in albums[0] and albums[1] are arbitrary | ||
| * values that can be anything as long as they are unique within the Map. | ||
| * The value of the indexes used in albums[0] and albums[1] are | ||
| * arbitrary values that only need to be unique within the Map. | ||
| * They do not correspond to the order of elements in albums because albums | ||
| * is a Set. | ||
| */ | ||
|
|
@@ -516,7 +518,7 @@ class AccountingController { | |
| ==== Data binding and type conversion errors | ||
|
|
||
|
|
||
| Sometimes when performing data binding it is not possible to convert a particular String into a particular target type. This results in a type conversion error. Grails will retain type conversion errors inside the link:{domainClassesRef}errors.html[errors] property of a Grails domain class. For example: | ||
| Sometimes when performing data binding it is not possible to convert a particular String into a particular target type. This results in a type conversion error. Grails will retain type conversion errors inside the link:{domainClassesRef}errors.html[errors] property of a Grails domain class. Invalid indexed array, List, and positional many-ended association binding paths, such as negative or non-integer indexes, are also retained as binding errors, and the field name identifies the indexed path that was rejected. For example: | ||
|
|
||
| [source,groovy] | ||
| ---- | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -800,6 +800,34 @@ class GrailsWebDataBinderSpec extends Specification implements DataTest { | |
| updatedA3.name == 'Author Tres' | ||
| } | ||
|
|
||
| void 'Test updating Set elements by id with non-numeric grouping keys'() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good test, and it does pin the regression I flagged. Two gaps that matter for keeping it pinned:
A plain non-domain |
||
|
|
||
| given: | ||
| def publisher = new Publisher(name: 'Some Publisher') | ||
|
|
||
| when: | ||
| def a1 = new Author(name: 'Author One').save() | ||
| def a2 = new Author(name: 'Author Two').save() | ||
| publisher.addToAuthors(a1) | ||
| publisher.addToAuthors(a2) | ||
|
|
||
| then: | ||
| a1.id != null | ||
| a2.id != null | ||
|
|
||
| when: | ||
| binder.bind(publisher, new SimpleMapDataBindingSource([ | ||
| 'authors[foo]': [id: a2.id, name: 'Author Dos'], | ||
| 'authors[bar]': [id: a1.id, name: 'Author Uno'] | ||
| ])) | ||
| def updatedA1 = publisher.authors.find { it.id == a1.id } | ||
| def updatedA2 = publisher.authors.find { it.id == a2.id } | ||
|
|
||
| then: | ||
| updatedA1.name == 'Author Uno' | ||
| updatedA2.name == 'Author Dos' | ||
| } | ||
|
|
||
| void 'Test updating Set elements by id'() { | ||
|
|
||
| given: | ||
|
|
@@ -957,6 +985,66 @@ class GrailsWebDataBinderSpec extends Specification implements DataTest { | |
| publisher.publications[0].title == 'Definitive Guide To Grails 2' | ||
| } | ||
|
|
||
| void 'Test negative indexed domain association binding to a List is rejected'() { | ||
|
|
||
| given: | ||
| def bindingErrors = [] as List<BindingError> | ||
| def listener = new DataBindingListenerAdapter() { | ||
| @Override void bindingError(BindingError error, Object errors) { | ||
| bindingErrors << error | ||
| } | ||
| } | ||
| def publication = new Publication(title: 'Definitive Guide To Grails', author: new Author(name: 'Author Name')) | ||
| def publisher = new Publisher(name: 'Apress').save() | ||
| publisher.addToPublications(publication) | ||
| publisher.save(flush: true) | ||
|
|
||
| when: | ||
| binder.bind(publisher, new SimpleMapDataBindingSource([ | ||
| 'publications[-1]': [ | ||
| id: publication.id, | ||
| title: 'Definitive Guide To Grails 2' | ||
| ] | ||
| ]), listener) | ||
|
|
||
| then: | ||
| publisher.publications.size() == 1 | ||
| publisher.publications[0].title == 'Definitive Guide To Grails' | ||
| bindingErrors.size() == 1 | ||
| bindingErrors[0].propertyName == 'publications[-1]' | ||
| bindingErrors[0].rejectedValue == [id: publication.id, title: 'Definitive Guide To Grails 2'] | ||
| } | ||
|
|
||
| void 'Test malformed indexed domain association binding to a List is rejected'() { | ||
|
|
||
| given: | ||
| def bindingErrors = [] as List<BindingError> | ||
| def listener = new DataBindingListenerAdapter() { | ||
| @Override void bindingError(BindingError error, Object errors) { | ||
| bindingErrors << error | ||
| } | ||
| } | ||
| def publication = new Publication(title: 'Definitive Guide To Grails', author: new Author(name: 'Author Name')) | ||
| def publisher = new Publisher(name: 'Apress').save() | ||
| publisher.addToPublications(publication) | ||
| publisher.save(flush: true) | ||
|
|
||
| when: | ||
| binder.bind(publisher, new SimpleMapDataBindingSource([ | ||
| 'publications[bad]': [ | ||
| id: publication.id, | ||
| title: 'Definitive Guide To Grails 2' | ||
| ] | ||
| ]), listener) | ||
|
|
||
| then: | ||
| publisher.publications.size() == 1 | ||
| publisher.publications[0].title == 'Definitive Guide To Grails' | ||
| bindingErrors.size() == 1 | ||
| bindingErrors[0].propertyName == 'publications[bad]' | ||
| bindingErrors[0].rejectedValue == [id: publication.id, title: 'Definitive Guide To Grails 2'] | ||
| } | ||
|
|
||
| void 'Test using @BindUsing to initialize property with a type other than the declared type'() { | ||
|
|
||
| given: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -434,6 +434,8 @@ class GrailsWebDataBinder extends SimpleDataBinder { | |
| if (referencedType != null && isDomainClass(referencedType)) { | ||
| needsBinding = false | ||
| if (Set.isAssignableFrom(metaProperty.type)) { | ||
| // Set association keys are grouping keys (not positions); do not | ||
| // require a numeric index. Selection is by id. | ||
| def collection = initializeCollection(obj, propName, metaProperty.type) | ||
| def instance | ||
| if (collection != null) { | ||
|
|
@@ -448,7 +450,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { | |
| Exception e = new IllegalArgumentException(message) | ||
| addBindingError(obj, propName, idValue, e, listener, errors) | ||
| } else { | ||
| addElementToCollectionAt(obj, propName, collection, Integer.parseInt(indexedPropertyReferenceDescriptor.index), instance) | ||
| addElementToCollectionAt(obj, propName, collection, 0, instance) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The behavior is right, but a bare Folding that into the comment you already added at the top of the branch would be enough, e.g. "...Selection is by id, and |
||
| } | ||
| } | ||
| if (instance != null) { | ||
|
|
@@ -459,8 +461,11 @@ class GrailsWebDataBinder extends SimpleDataBinder { | |
| } | ||
| } | ||
| } else if (Collection.isAssignableFrom(metaProperty.type)) { | ||
| Integer idx = parseIndexedPropertyIndex(obj, indexedPropertyReferenceDescriptor, val, listener, errors) | ||
| if (idx == null) { | ||
| return | ||
| } | ||
| def collection = initializeCollection(obj, propName, metaProperty.type) | ||
| def idx = Integer.parseInt(indexedPropertyReferenceDescriptor.index) | ||
| if ('null' == idValue) { | ||
| if (idx < collection.size()) { | ||
| def element = collection[idx] | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the other half of the
Setissue I raised on #15804, and it is still open.Setreaches this branch (Collection.isAssignableFrom(Set)is true), so aSet-typed property still requires a non-negative integer key here — even though the parsed index is dead forSets:isOkToAddElementAtignoresindexwhen the collection is aSet(it only checkscollection.size() < autoGrowCollectionLimit), andaddElementToCollectionAtthen callscollection.add(val). Nothing downstream consumes the value.The consequence is that the fix in
GrailsWebDataBinderonly covers the update-existing-by-id path. When the map has noid,getIdentifierValueFrom(val)returns null,needsBindingstays 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:A plain non-domain
Setis rejected outright:setOfCodes[foo]yields a binding error and leaves the property null.Please move the parse inside the non-
Setpath so the index is only required where it is actually used, e.g.:That makes the core binder agree with the
Setbranch inGrailsWebDataBinderand with the guide text this PR adds.