Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -333,13 +333,19 @@ class SimpleDataBinder implements DataBinder {
}

if (propertyType.isArray()) {
def index = Integer.parseInt(indexedPropertyReferenceDescriptor.index)
def index = parseIndex(indexedPropertyReferenceDescriptor.index)
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)
def index = parseIndex(indexedPropertyReferenceDescriptor.index)
if (index == null) {
return
}
Collection collectionInstance = initializeCollection(obj, propName, propertyType)
def indexedInstance = null
if (!(Set.isAssignableFrom(propertyType))) {
Expand Down Expand Up @@ -394,6 +400,15 @@ class SimpleDataBinder implements DataBinder {
}
}

protected Integer parseIndex(String index) {
try {
Integer.parseInt(index)
}
catch (NumberFormatException e) {
null
}
}

@CompileStatic(TypeCheckingMode.SKIP)
protected initializeArray(obj, String propertyName, Class arrayType, int index) {
Object[] array = obj[propertyName]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,32 @@ class SimpleDataBinderSpec extends Specification {
widget.names[2] == 'two'
}

void 'Test binding a non-numeric indexed property to a List does not throw'() {
given:
def binder = new SimpleDataBinder()
def widget = new Widget(names: ['existing'])

when:
binder.bind widget, new SimpleMapDataBindingSource(['names[abc]': 'hacked'])

then:
noExceptionThrown()
widget.names == ['existing']
}

void 'Test binding a non-numeric indexed property to an array does not throw'() {
given:
def binder = new SimpleDataBinder()
def widget = new Widget()

when:
binder.bind widget, new SimpleMapDataBindingSource(['integers[abc]': 42])

then:
noExceptionThrown()
widget.integers == null
}

void 'Test @BindUsing on a List<Integer>'() {
given:
def binder = new SimpleDataBinder()
Expand Down
6 changes: 5 additions & 1 deletion grails-doc/src/en/guide/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1557,6 +1557,11 @@ include::ref/Controllers/allowedMethods.adoc[]

include::ref/Controllers/bindData.adoc[]

[[ref-controllers-secureBindData]]
==== secureBindData

include::ref/Controllers/secureBindData.adoc[]

[[ref-controllers-chain]]
==== chain

Expand Down Expand Up @@ -2542,4 +2547,3 @@ include::ref/Tags/uploadForm.adoc[]
==== while

include::ref/Tags/while.adoc[]

5 changes: 5 additions & 0 deletions grails-doc/src/en/guide/reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,11 @@ include::ref/Controllers/allowedMethods.adoc[]

include::ref/Controllers/bindData.adoc[]

[[ref-controllers-secureBindData]]
==== secureBindData

include::ref/Controllers/secureBindData.adoc[]

[[ref-controllers-chain]]
==== chain

Expand Down
52 changes: 52 additions & 0 deletions grails-doc/src/en/guide/theWebLayer/controllers/dataBinding.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1087,4 +1087,56 @@ bindData(p, params, [include: ['firstName', 'lastName']])

NOTE: If an empty List is provided as a value for the `include` parameter then all fields will be subject to binding if they are not explicitly excluded.

For new code that binds request parameters, prefer link:{controllersRef}secureBindData.html[secureBindData]. It requires the list of allowed properties:

[source,groovy]
----
def p = new Person()
secureBindData(p, params, ['firstName', 'lastName'])
----

WARNING: The allowlist must be developer-controlled: a literal list of property names, or a reference to a `static final` constant list. Building it from `params`, `request`, or other request-derived data is a compilation error, since it would let an attacker choose which properties are bindable.

Use nested property paths when allowing nested binding:

[source,groovy]
----
def p = new Person()
secureBindData(p, params, ['firstName', 'homeAddress.street', 'homeAddress.city'])
----

Use logical collection and map paths in the allowlist. The same allowlist path applies to indexed request parameters, so `members.name` allows `members[0].name` and `contributors.expertise` allows `contributors[lead].expertise`:

[source,groovy]
----
def team = new Team()
secureBindData(team, params, ['name', 'members.name', 'members.role'])
----

Prefix filtering is supported:

[source,groovy]
----
def p = new Person()
secureBindData(p, params, ['firstName', 'lastName'], 'author')
----

You can also clear allowed properties that are missing from the request:

[source,groovy]
----
def p = Person.get(1)
secureBindData(p, params, ['firstName', 'lastName'], nullMissing: true)
----

When binding a request body into a collection, `secureBindData` also requires the allowed properties for each created item:

[source,groovy]
----
def people = []
secureBindData(Person, people, request, ['firstName', 'lastName'])
----

An empty allowlist binds no properties. This differs from `bindData` with an empty `include` list, which leaves all otherwise-bindable properties eligible for binding.

The link:{constraintsRef}bindable.html[bindable] constraint can be used to globally prevent data binding for certain properties.
2 changes: 2 additions & 0 deletions grails-doc/src/en/ref/Controllers/bindData.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ Arguments:

NOTE: Note that if an empty List or no List is provided as a value for the `include` parameter then all statically typed instance properties will be subject to binding if they are not explicitly excluded. See the link:{constraintsRefFromRef}bindable.html[bindable] constraint documentation for more information on how to control what is bindable and what is not.

For new code that binds untrusted request parameters, prefer link:secureBindData.html[secureBindData]. It requires an explicit allowlist, treats an empty allowlist as binding no properties, and fails controller compilation if the allowlist is omitted.

The underlying implementation uses Spring's Data Binding framework. If the target is a domain class, type conversion errors are stored in the `errors` property of the domain class.

Refer to the section on link:{guidePath}theWebLayer.html#dataBinding[data binding] in the user guide for more information.
77 changes: 77 additions & 0 deletions grails-doc/src/en/ref/Controllers/secureBindData.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
////
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
////

== secureBindData

=== Purpose

Binds request parameters to an object or collection using an explicit allowlist of property names.

=== Examples

[source,groovy]
----
// binds only firstName and lastName
secureBindData(target, params, ['firstName', 'lastName'])

// indexed request parameters use logical allowlist paths
secureBindData(team, params, ['name', 'members.name', 'members.role'])

// binds only allowed author.* parameters
secureBindData(target, params, ['firstName', 'lastName'], 'author')

// clears allowed properties that are missing from the request
secureBindData(target, params, ['firstName', 'lastName'], nullMissing: true)

// binds request body items into a collection using an allowlist for each item
secureBindData(Person, people, request, ['firstName', 'lastName'])
----

=== Description

Usage: `secureBindData(target, params, allowedParams, prefix*)`

Usage: `secureBindData(target, params, allowedParams, Map options)`

Usage: `secureBindData(options, target, params, allowedParams, prefix*)`

Usage: `secureBindData(targetType, collectionToPopulate, request, allowedParams)`

Arguments:

* `target` - The target object to bind to
* `params` - A `Map` of source parameters, often the link:params.html[params] object when used in a controller
* `allowedParams` - A required list of property names or nested property paths that may be bound
* `prefix` - (Optional) A string representing a prefix to use to filter parameters. The method will automatically append a '.' when matching the prefix to parameters, so you can use 'author' to filter for parameters such as 'author.name'.
* `options` - (Optional) A map of binding options. The `nullMissing` option clears allowed properties that are missing from the source.
* `targetType` - The type of object to create when binding to a collection
* `collectionToPopulate` - The collection to populate
* `request` - The request body source for collection binding

The `allowedParams` list is required. In controllers, calling `secureBindData` without an explicit allowlist is a compilation error.

WARNING: `allowedParams` must be developer-controlled. It must be a literal list of property names, such as `['firstName', 'lastName']`, or a reference to a `static final` constant list. Building the list from `params`, `request`, or any other request-derived data is a compilation error in controllers, since it would let an attacker choose which properties are bindable and defeat the purpose of `secureBindData`.

An empty allowlist binds no properties. This differs from link:bindData.html[bindData] with an empty `include` list, which leaves all otherwise-bindable properties eligible for binding.

Use nested property paths such as `homeAddress.street` to allow nested binding. Properties not named in `allowedParams` are ignored.

Use logical collection and map paths in `allowedParams`. For example, `members.name` allows request parameters such as `members[0].name`, and `contributors.expertise` allows map parameters such as `contributors[lead].expertise`. The same logical paths are used with `nullMissing`; missing allowed nested values are cleared on the matching indexed collection or map element.

Refer to the section on link:{guidePath}theWebLayer.html#dataBinding[data binding] in the user guide for more information.
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,75 @@ class AdvancedDataBindingController {
contributors: contributorsMap
] as JSON)
}


def secureBindEmployee() {
def employee = new Employee()
secureBindData(employee, params, ['firstName', 'homeAddress.street', 'homeAddress.city'])
render([
firstName: employee.firstName,
email: employee.email,
homeAddress: employee.homeAddress ? [
street: employee.homeAddress.street,
city: employee.homeAddress.city,
state: employee.homeAddress.state
] : null,
workAddress: employee.workAddress ? [
street: employee.workAddress.street,
city: employee.workAddress.city,
state: employee.workAddress.state
] : null
] as JSON)
}

def secureBindTeamWithMembers() {
def team = new Team()
secureBindData(team, params, ['name', 'members.name', 'members.role'])
render([
name: team.name,
members: team.members?.findAll { it != null }?.collect { [name: it.name, role: it.role] } ?: []
] as JSON)
}

def secureBindProjectWithContributors() {
def project = new Project()
secureBindData(project, params, ['name', 'contributors.name', 'contributors.expertise'])
def contributorsMap = project.contributors?.collectEntries { k, v ->
[k, [name: v?.name, expertise: v?.expertise]]
} ?: [:]
render([
name: project.name,
contributors: contributorsMap
] as JSON)
}

def secureBindWithPrefix() {
def employee = new Employee()
secureBindData(employee, params, ['firstName', 'lastName'], 'employee')
render([
firstName: employee.firstName,
lastName: employee.lastName,
email: employee.email
] as JSON)
}

def secureBindWithNullMissing() {
def employee = new Employee(firstName: 'Existing', email: 'existing@example.com')
secureBindData(employee, params, ['firstName', 'email'], nullMissing: true)
render([
firstName: employee.firstName,
email: employee.email
] as JSON)
}

def secureBindWithEmptyAllowlist() {
def employee = new Employee(firstName: 'Existing', email: 'existing@example.com')
secureBindData(employee, params, [])
render([
firstName: employee.firstName,
email: employee.email
] as JSON)
}

/**
* Test binding with @RequestParameter annotation.
*/
Expand Down
Loading
Loading