Add opt-in deny-by-default data binding and always honor bindable:false - #15947
Add opt-in deny-by-default data binding and always honor bindable:false#15947jamesfredley wants to merge 18 commits into
Conversation
Default data binding now denies properties unless they are explicitly marked bindable or named by @BindAllowed on action parameters. The legacy grails.databinding.legacyBindableDefault flag keeps old opt-out binding behavior while preserving bindable:false and domain special-property exclusions. Assisted-by: Sisyphus-Junior:openai/gpt-5.5 codex
There was a problem hiding this comment.
Pull request overview
This PR hardens Grails’ mass data binding to mitigate mass-assignment (CWE-915) by switching to an explicit allowlist model for bindData defaults and controller action auto-binding, while providing a legacy opt-out for migration.
Changes:
- Default binding behavior becomes deny-by-default unless properties are explicitly opted in (primarily via
bindable: trueallowlists generated at compile time). - Introduces
@BindAllowed([...])for controller action parameters to provide action-specific binding allowlists. - Adds a migration switch (
grails.databinding.legacyBindableDefault=true), updates documentation, and expands test coverage for the new defaults and edge cases (including emptyinclude).
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| grails-web-databinding/src/main/groovy/org/grails/web/databinding/DefaultASTDatabindingHelper.java | Generates both default and legacy binding allowlist fields during AST injection. |
| grails-web-databinding/src/main/groovy/grails/web/databinding/DataBindingUtils.java | Enforces deny-by-default include behavior and adds legacy-default config support. |
| grails-web-databinding/src/main/groovy/grails/web/databinding/DataBinder.groovy | Treats explicit empty include as “bind nothing” to match new semantics. |
| grails-web-databinding/src/main/groovy/grails/web/databinding/BindAllowed.java | Adds @BindAllowed annotation for controller action parameter allowlists. |
| grails-test-suite-web/src/test/groovy/org/grails/web/servlet/BindDataMethodTests.groovy | Updates and extends bindData tests for deny-by-default and empty-include behavior. |
| grails-test-suite-web/src/test/groovy/org/grails/web/commandobjects/SomeValidateableClass.groovy | Updates constraints to explicitly opt properties into binding. |
| grails-test-suite-web/src/test/groovy/org/grails/web/commandobjects/NonValidateableCommand.groovy | Adds constraints to opt properties into binding under the new default model. |
| grails-test-suite-web/src/test/groovy/org/grails/web/commandobjects/CommandObjectsSpec.groovy | Adds @BindAllowed coverage and updates command-object constraints for new defaults. |
| grails-test-suite-web/src/test/groovy/org/grails/web/binding/DefaultASTDatabindingHelperDomainClassSpecialPropertiesSpec.groovy | Expands coverage for default deny behavior and legacy flag behavior in domain inheritance/special properties. |
| grails-doc/src/en/ref/Controllers/bindData.adoc | Documents new default allowlist behavior, empty-include semantics, and @BindAllowed. |
| grails-doc/src/en/ref/Constraints/bindable.adoc | Updates bindable semantics to reflect explicit opt-in model and legacy migration option. |
| grails-doc/src/en/guide/upgrading.adoc | Adds upgrading notes for the new data binding defaults and migration flag. |
| grails-doc/src/en/guide/theWebLayer/controllers/dataBinding.adoc | Updates guide note for empty include behavior and documents allowlist options. |
| grails-controllers/src/main/groovy/org/grails/compiler/web/ControllerActionTransformer.java | Threads @BindAllowed allowlists into command object initialization during action parameter binding. |
| grails-controllers/src/main/groovy/grails/artefact/Controller.groovy | Adds an overload of initializeCommandObject that accepts allowlisted bind properties and applies them during binding. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
The issue described regarding |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #15947 +/- ##
==================================================
- Coverage 52.3566% 52.2724% -0.0841%
- Complexity 18300 18380 +80
==================================================
Files 2036 2036
Lines 96347 96856 +509
Branches 16829 16961 +132
==================================================
+ Hits 50444 50629 +185
- Misses 38481 38751 +270
- Partials 7422 7476 +54
🚀 New features to boost your workflow:
|
Keep legacy bind-all as the default so scaffolded apps and existing tests keep working. Secure mode is enabled with grails.databinding.denyByDefault=true. @BindAllowed and bindable:false remain available. Walk declared-field hierarchy for whitelist lookup. Assisted-by: Sisyphus:xai/grok-4.5 [gpt-coding]
getBindingIncludeList looked up the generated allowlist field with getDeclaredField, which fails for CGLIB/ByteBuddy/Hibernate proxy subclasses and, under deny-by-default binding, silently bound nothing. Use getField so the inherited public static allowlist field on the superclass resolves for proxied instances. Adds a subclass regression test to BindDataMethodTests. Assisted-by: Sisyphus:openai/gpt-5.6-terra [gpt-coding]
Grails data binding bound any domain/command property by default, so a request could set fields the developer never intended (mass assignment, CWE-915). This makes binding deny-by-default: only properties that are explicitly allowlisted are bound from request parameters. - Invert the default so an unconfigured Grails 8 app binds only allowlisted properties; grails.databinding.legacyBindableDefault=true restores the previous permissive behavior for the whole application. - Honor existing allowlists unchanged: bindable: true constraints (including those resolved at runtime via importFrom / shared constraints), explicit include: lists, and @BindAllowed on controller action parameters. Existing correct usage needs no changes. - Apply the allowlist through every recursive path - nested domain associations, collections, object arrays, indexed properties, typed maps, and JSON-shaped input - so nested properties cannot be widened past the child's own allowlist. Normalize the public bind overloads so a null include resolves the allowlist and an empty include binds nothing; intentional bind-all flows through a private marker. - Resolve inherited allowlist fields safely for proxy subclasses and only trust a generated allowlist when both generated fields are co-declared on the same class (mixed-generation upgrade safety). Value types with no no-arg constructor fail closed in secure mode rather than mass-assigning through a Map constructor. - Emit a single clear, actionable warning when deny-by-default drops a request parameter, naming the property and class and the exact remedies (bindable: true / include / @BindAllowed / the legacy flag). - Document the behavior and migration, and migrate the affected tests. Assisted-by: Sisyphus:openai/gpt-5.6-sol [gpt-coding]
- Route non-indexed typed-array element binding (e.g. JSON `children: [[...]]` into a `Child[]`) through the allowlist-aware binder instead of raw Groovy list-to-array coercion, so a nested property such as `admin` cannot be set past the element's allowlist. - Resolve persisted domain array elements by identifier before binding, mirroring the collection and typed-map paths. - Resolve the runtime-derived bindable property names only on an include-list cache miss, keeping the hot binding path off the constraints/metaclass walk on cached classes. Assisted-by: Sisyphus:openai/gpt-5.6-sol [gpt-coding]
Reworked to deny-by-default (this supersedes the earlier opt-in approach)Per maintainer direction this PR now makes Grails data binding deny-by-default rather than an opt-in flag. It went through an extensive dual security review; here is the final shape. The contract
Security enforcement (the review closed each of these bypass paths, all with regression tests)
Verification: Follow-up (non-security, documented): the nested-collection branches (array / typed map) currently bind elements before the parent Related: #15808 (the earlier |
Assisted-by: opencode:gpt-5.6-sol
Assisted-by: opencode:gpt-5.6-sol
|
There seems to be some static state leak of |
…r and JsonRenderer specs This way the tests work regardless if deny-by-default is configured or not.
`grails.databinding.legacyBindableDefault` defaults to true, keeping data binding permissive unless an application explicitly opts in to the secure deny-by-default mode. Two resolution defects meant that default was not applied as documented. When no GrailsApplication was available the value was read from the flat config, which answers an absent key with a NullSafeNavigator placeholder rather than null. The absent-key branch therefore never matched and an unconfigured application silently ran in secure mode, which is what made HalJsonRendererSpec and JsonRendererSpec appear to leak static state. When an application was available the value was read through a typed Boolean lookup, and NavigableMapConfig discards a converted Boolean.FALSE in favour of the supplied default. An explicit false supplied as a string by a properties file, a system property or an environment variable was therefore ignored, leaving mass assignment enabled. Both paths now read the raw value and share a single resolver: an absent key falls back to the permissive default, a Boolean is honoured directly, and any other value must read as true to stay permissive, so unrecognised input fails closed. Assisted-by: Sisyphus:anthropic/claude-opus-5
These constraints were added to work around the permissive binding default not being applied, and they masked that defect rather than fixing it. The default now resolves correctly, so the model classes in HalJsonRendererSpec and JsonRendererSpec bind implicitly again, restoring the coverage of an unconfigured application these specs are meant to provide. Assisted-by: Sisyphus:anthropic/claude-opus-5
|
@matrei Good catch - you were right that the default was not active, and the cause was worse than the specs suggested. Fixed in 4c9062e and 08bf781. What was actually happeningIt was not leaked state between specs - it reproduced with those two spec classes selected alone and When there is no value == null || Boolean.TRUE.equals(value) || "true".equalsIgnoreCase(String.valueOf(value))evaluated to A second, more serious defect found while reviewing thisThe return DefaultGroovyMethods.asBoolean(value) ? value : defaultValue;A configured The fixBoth branches now read the raw value and share one resolver:
Explicitly-supplied values keep exactly their previous meaning; only the absent-key case changes, plus string-sourced values are now honored in the application branch. Unrecognised input deliberately fails closed rather than throwing, since raising at bind time on a typo'd setting would be a worse regression than choosing the safe mode. CoverageNew Green: |
|
The TestLens failures for Fix: #16067 (replaces the |
jdaugherty
left a comment
There was a problem hiding this comment.
Making this opt-in and keeping bindable: false unconditional is the right shape, and the nested/collection/map enforcement is thorough.
My main concern is that the suite no longer verifies the shipping default. BindDataMethodTests — the primary bindData regression spec — is flipped to legacyBindableDefault=false for every feature via a new setup(), and roughly 25 pre-existing fixture classes across grails-test-suite-web, grails-databinding and grails-test-suite-persistence gained bindable: true on every property.
I reverted the constraint-only fixture edits on this branch and re-ran the affected specs: 25 classes / 151 tests pass unchanged. So the new constraints are not required by the default. But adding them means those fixtures bind identically in both modes, so none of those specs can fail if the compatible path regresses — which is the claim this PR rests on.
Beyond the test coverage, there are a few behavior and cost changes that reach the default path as well. Details inline.
Keep the shipping default permissive and scope secure-mode coverage to explicit specs. Cache include-list misses and class-derived unbindable names, restore exclude-only bind-all in compatibility mode, tighten Map-constructor fallback try/catch, warn on unrecognised legacyBindableDefault values, and document empty-include / typed-map / exclude-only behavior. Assisted-by: Sisyphus:xai/grok-4.5 [gpt-coding]
|
@jdaugherty Addressed in 73a0555 (after merging latest Review responses
Verification
|
Use the positive denyByDefault setting throughout binding code, tests, metadata, and documentation. Preserve nested listener notifications and apply the requested controller and import style corrections. Assisted-by: Sisyphus:gpt-5.6-sol [gpt-sol] [codex-review]
✅ All tests passed ✅🏷️ Commit: b89e6a0 Learn more about TestLens at testlens.app. |
sbglasius
left a comment
There was a problem hiding this comment.
Used AI to review and it had the following comment:
-
SimpleDataBinder.groovy:273 —
isOkToBind(String,...)only checks the.wildcard suffix, but the PR's new whitelist generator also emits_suffix markers, so underscore/indexed-convention properties get wrongly denied under deny-by-default mode. -
DefaultASTDatabindingHelper.java:250 —
getLegacyPropertyNamesToIncludeInWhiteListduplicates ~80 lines ofgetPropertyNamesToIncludeInWhiteListinstead of sharing the traversal. -
DataBindingUtils.java:556 —
bindObjectToInstanceandbindObjectToDomainInstanceduplicate the same include-list normalization block, and the former's copy is redundant since it delegates straight into the latter.
But otherwise it looks good to me.
Description
Grails data binding stays compatible by default and gains an opt-in deny-by-default mode that hardens
bindDataagainst mass assignment (OWASP / CWE-915). Unconfigured applications keep binding exactly as they did in prior Grails versions; applications that want mass-assignment protection enable secure mode with a single property.Independently of the default, an explicit
bindable: falseconstraint is now honored in every mode - including the nestedMap-constructor fallback and the explicit bind-all path - so a property a developer marked non-bindable can never be mass-assigned.The contract
grails.databinding.legacyBindableDefault=falsebindable: falseon a propertybindable: true(incl.importFrom/ shared constraints), explicitinclude:lists, and@BindAllowed([...])are all honoredbindData(compatibility mode)includelistSimpleDataBindercallers (not restored by the compatibility flag)What changed
grails.databinding.legacyBindableDefault=falseopts in to deny-by-defaultbindable: falseMap-constructor fallback and the explicit bind-all pathbindable: trueallowlist (compile-time and runtimeimportFrom/ shared constraints, unioned), explicitinclude:lists, and@BindAllowed([...])on action parametersLists, object arrays, indexed properties, typedMap<K,V>values, JSON-shaped nested objects, and the listener fallbackMapvaluesbeforeBindingcan veto before mutation; getter-only maps are updated in place; conversion failures register binding errorsSettings.LEGACY_BINDABLE_DEFAULTis the public key; unrecognised values log a warning and fail closed to secure modebind(...)overloads normalize anullinclude to the resolved allowlist and an empty include to bind-nothing; intentional bind-all uses a private markerbindDatasurfaceEnabling secure (deny-by-default) mode
Verification
:grails-web-databinding:test,:grails-databinding-core:test, and focused:grails-test-suite-web:test(including permissive-default, explicit-secure,bindable: false, exclude-only, and bind-all regressions) pass.Related
secureBindDataexplicit-API approach)nullMissing/ stale-data clearing)Contributor Checklist
bindable: falsealways honoredbindDataregressions (not only secure mode)8.0.xai-generated-starting-pointlabel appliedAssisted-by: Sisyphus:xai/grok-4.5 [gpt-coding]