You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/07/01 22:59:32 UTC

(grails-core) 02/04: Add secure binding controller validation

This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch feat/secure-bind-data
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 662acc5be1aac26511924a461987136c0c9455d4
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Tue Jun 30 23:42:14 2026 -0400

    Add secure binding controller validation
    
    Require controller secureBindData calls to provide an explicit allowlist at compile time, including trait helper and inherited action paths. Document secureBindData in the controller reference and data binding guide.
    
    Assisted-by: Hephaestus:gpt-5.5
---
 .../compiler/web/ControllerActionTransformer.java  | 251 ++++++++++++
 ...erActionTransformerCompilationErrorsSpec.groovy | 422 +++++++++++++++++++++
 grails-doc/src/en/guide/index.adoc                 |   6 +-
 grails-doc/src/en/guide/reference.adoc             |   5 +
 .../guide/theWebLayer/controllers/dataBinding.adoc |  42 ++
 grails-doc/src/en/ref/Controllers/bindData.adoc    |   2 +
 .../src/en/ref/Controllers/secureBindData.adoc     |  68 ++++
 7 files changed, 795 insertions(+), 1 deletion(-)

diff --git a/grails-controllers/src/main/groovy/org/grails/compiler/web/ControllerActionTransformer.java b/grails-controllers/src/main/groovy/org/grails/compiler/web/ControllerActionTransformer.java
index 280249d6fe..bfb607867f 100644
--- a/grails-controllers/src/main/groovy/org/grails/compiler/web/ControllerActionTransformer.java
+++ b/grails-controllers/src/main/groovy/org/grails/compiler/web/ControllerActionTransformer.java
@@ -25,8 +25,10 @@ import java.net.URL;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.regex.Pattern;
 
 import groovy.lang.Closure;
@@ -36,6 +38,7 @@ import org.codehaus.groovy.ast.ASTNode;
 import org.codehaus.groovy.ast.AnnotationNode;
 import org.codehaus.groovy.ast.ClassHelper;
 import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.CodeVisitorSupport;
 import org.codehaus.groovy.ast.ConstructorNode;
 import org.codehaus.groovy.ast.FieldNode;
 import org.codehaus.groovy.ast.MethodNode;
@@ -189,6 +192,7 @@ public class ControllerActionTransformer implements GrailsArtefactClassInjector,
             new ClassNode(Action.class));
     private static final String ACTION_MEMBER_TARGET = "commandObjects";
     public static final String EXCEPTION_HANDLER_META_DATA_FIELD_NAME = "$exceptionHandlerMetaData";
+    private static final String SECURE_BIND_DATA_METHOD_NAME = "secureBindData";
 
     private static final TupleExpression EMPTY_TUPLE = new TupleExpression();
     @SuppressWarnings({"unchecked"})
@@ -237,6 +241,7 @@ public class ControllerActionTransformer implements GrailsArtefactClassInjector,
     public void performInjectionOnAnnotatedClass(SourceUnit source, GeneratorContext context, ClassNode classNode) {
         final String className = classNode.getName();
         if (className.endsWith(ControllerArtefactHandler.TYPE)) {
+            validateSecureBindDataCalls(source, classNode);
             processMethods(classNode, source, context);
             processClosures(classNode, source, context);
         }
@@ -259,6 +264,252 @@ public class ControllerActionTransformer implements GrailsArtefactClassInjector,
         return isExceptionHandler;
     }
 
+    private void validateSecureBindDataCalls(SourceUnit source, ClassNode classNode) {
+        Set<ClassNode> visitedClasses = new HashSet<>();
+        Set<MethodNode> visitedMethods = new HashSet<>();
+        Set<ClassNode> visitedTraitHelpers = new HashSet<>();
+        validateSecureBindDataCalls(source, classNode, classNode, visitedClasses, visitedMethods, visitedTraitHelpers);
+    }
+
+    private void validateSecureBindDataCalls(SourceUnit source, ClassNode controllerClassNode, ClassNode classNode, Set<ClassNode> visitedClasses, Set<MethodNode> visitedMethods, Set<ClassNode> visitedTraitHelpers) {
+        if (classNode == null || OBJECT_CLASS.equals(classNode) || !visitedClasses.add(classNode)) {
+            return;
+        }
+        SecureBindDataCallVisitor visitor = new SecureBindDataCallVisitor(source, controllerClassNode, classNode);
+        for (MethodNode method : classNode.getMethods()) {
+            if (method.getCode() != null && visitedMethods.add(method)) {
+                method.getCode().visit(visitor);
+            }
+            validateTraitHelperSecureBindDataCalls(source, method, controllerClassNode, visitedTraitHelpers);
+        }
+        for (PropertyNode property : classNode.getProperties()) {
+            Expression initialExpression = property.getInitialExpression();
+            if (initialExpression instanceof ClosureExpression) {
+                ((ClosureExpression) initialExpression).getCode().visit(visitor);
+            }
+        }
+        validateSecureBindDataCalls(source, controllerClassNode, classNode.getSuperClass(), visitedClasses, visitedMethods, visitedTraitHelpers);
+    }
+
+    private void validateTraitHelperSecureBindDataCalls(SourceUnit source, MethodNode method, ClassNode controllerClassNode, Set<ClassNode> visitedTraitHelpers) {
+        List<AnnotationNode> traitBridges = method.getAnnotations(new ClassNode(Traits.TraitBridge.class));
+        if (traitBridges.size() == 1) {
+            Expression traitClass = traitBridges.get(0).getMember("traitClass");
+            if (traitClass instanceof ClassExpression) {
+                ClassNode helperClass = Traits.findHelper(traitClass.getType());
+                if (helperClass != null && visitedTraitHelpers.add(helperClass)) {
+                    SecureBindDataCallVisitor visitor = new SecureBindDataCallVisitor(source, controllerClassNode, traitClass.getType(), traitClass.getType());
+                    for (MethodNode helperMethod : helperClass.getMethods()) {
+                        if (helperMethod.getCode() != null) {
+                            helperMethod.getCode().visit(visitor);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private static class SecureBindDataCallVisitor extends CodeVisitorSupport {
+
+        private final SourceUnit source;
+        private final ClassNode controllerClassNode;
+        private final ClassNode visitedClassNode;
+        private final ClassNode traitClassNode;
+
+        SecureBindDataCallVisitor(SourceUnit source, ClassNode controllerClassNode, ClassNode visitedClassNode) {
+            this(source, controllerClassNode, visitedClassNode, null);
+        }
+
+        SecureBindDataCallVisitor(SourceUnit source, ClassNode controllerClassNode, ClassNode visitedClassNode, ClassNode traitClassNode) {
+            this.source = source;
+            this.controllerClassNode = controllerClassNode;
+            this.visitedClassNode = visitedClassNode;
+            this.traitClassNode = traitClassNode;
+        }
+
+        @Override
+        public void visitMethodCallExpression(MethodCallExpression call) {
+            super.visitMethodCallExpression(call);
+            if (isControllerSecureBindDataCall(call) && !hasAllowedParamsArgument(call)) {
+                GrailsASTUtils.error(source, call,
+                        "secureBindData requires an explicit allowedParams list. Use secureBindData(target, params, allowedParams).");
+            }
+        }
+
+        private boolean isControllerSecureBindDataCall(MethodCallExpression call) {
+            if (!SECURE_BIND_DATA_METHOD_NAME.equals(call.getMethodAsString())) {
+                return false;
+            }
+            if (matchesDeclaredControllerMethod(call)) {
+                return false;
+            }
+            Expression objectExpression = call.getObjectExpression();
+            return call.isImplicitThis() ||
+                    (objectExpression instanceof VariableExpression && isControllerReceiver((VariableExpression) objectExpression));
+        }
+
+        private boolean matchesDeclaredControllerMethod(MethodCallExpression call) {
+            MethodNode methodTarget = call.getMethodTarget();
+            if (methodTarget != null && !methodTarget.isStatic() && declaresControllerMethod(methodTarget)) {
+                return true;
+            }
+
+            List<Expression> arguments = getArguments(call);
+            if (arguments == null) {
+                return false;
+            }
+            ClassNode currentClassNode = controllerClassNode;
+            while (currentClassNode != null && !OBJECT_CLASS.equals(currentClassNode)) {
+                List<MethodNode> declaredMethods = currentClassNode.getDeclaredMethods(SECURE_BIND_DATA_METHOD_NAME);
+                if (declaredMethods != null) {
+                    for (MethodNode method : declaredMethods) {
+                        if ((currentClassNode.equals(visitedClassNode) || !method.isPrivate()) && acceptsArguments(method, arguments)) {
+                            return true;
+                        }
+                    }
+                }
+                currentClassNode = currentClassNode.getSuperClass();
+            }
+            if (traitClassNode != null) {
+                List<MethodNode> declaredTraitMethods = traitClassNode.getDeclaredMethods(SECURE_BIND_DATA_METHOD_NAME);
+                if (declaredTraitMethods != null) {
+                    for (MethodNode method : declaredTraitMethods) {
+                        if (acceptsArguments(method, arguments)) {
+                            return true;
+                        }
+                    }
+                }
+            }
+            return false;
+        }
+
+        private boolean declaresControllerMethod(MethodNode methodTarget) {
+            ClassNode currentClassNode = controllerClassNode;
+            while (currentClassNode != null && !OBJECT_CLASS.equals(currentClassNode)) {
+                if (currentClassNode.equals(methodTarget.getDeclaringClass())) {
+                    return true;
+                }
+                currentClassNode = currentClassNode.getSuperClass();
+            }
+            return false;
+        }
+
+        private List<Expression> getArguments(MethodCallExpression call) {
+            Expression arguments = call.getArguments();
+            if (!(arguments instanceof TupleExpression)) {
+                return null;
+            }
+            return ((TupleExpression) arguments).getExpressions();
+        }
+
+        private boolean acceptsArguments(MethodNode method, List<Expression> arguments) {
+            if (method.isStatic()) {
+                return false;
+            }
+            Parameter[] parameters = method.getParameters();
+            int argumentCount = arguments.size();
+            int requiredParameterCount = 0;
+            for (Parameter parameter : parameters) {
+                if (!parameter.hasInitialExpression()) {
+                    requiredParameterCount++;
+                }
+            }
+            if (parameters.length > 0 && parameters[parameters.length - 1].getType().isArray()) {
+                if (argumentCount < requiredParameterCount - 1) {
+                    return false;
+                }
+            }
+            else if (argumentCount < requiredParameterCount || argumentCount > parameters.length) {
+                return false;
+            }
+
+            for (int i = 0; i < argumentCount; i++) {
+                if (!acceptsArgument(parameters, i, arguments.get(i))) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        private boolean acceptsArgument(Parameter[] parameters, int argumentIndex, Expression argument) {
+            ClassNode parameterType = getParameterType(parameters, argumentIndex);
+            if (parameterType == null || OBJECT_CLASS.equals(parameterType) || ClassHelper.OBJECT_TYPE.equals(parameterType)) {
+                return true;
+            }
+            ClassNode argumentType = argument.getType();
+            if (argumentType == null || OBJECT_CLASS.equals(argumentType) || ClassHelper.OBJECT_TYPE.equals(argumentType)) {
+                return isParamsArgument(argument) && acceptsMapArgument(parameterType);
+            }
+            return argumentType.equals(parameterType) || argumentType.isDerivedFrom(parameterType) || argumentType.implementsInterface(parameterType);
+        }
+
+        private boolean isParamsArgument(Expression argument) {
+            return argument instanceof VariableExpression && "params".equals(((VariableExpression) argument).getName());
+        }
+
+        private boolean acceptsMapArgument(ClassNode parameterType) {
+            return parameterType.equals(ClassHelper.MAP_TYPE) || parameterType.isDerivedFrom(ClassHelper.MAP_TYPE) || parameterType.implementsInterface(ClassHelper.MAP_TYPE);
+        }
+
+        private ClassNode getParameterType(Parameter[] parameters, int argumentIndex) {
+            int parameterIndex = Math.min(argumentIndex, parameters.length - 1);
+            ClassNode parameterType = parameters[parameterIndex].getType();
+            if (parameters.length > 0 && parameterIndex == parameters.length - 1 && parameterType.isArray()) {
+                return parameterType.getComponentType();
+            }
+            return parameterType;
+        }
+
+        private boolean isControllerReceiver(VariableExpression objectExpression) {
+            String variableName = objectExpression.getName();
+            return "this".equals(variableName) || "$self".equals(variableName);
+        }
+
+        private boolean hasAllowedParamsArgument(MethodCallExpression call) {
+            Expression arguments = call.getArguments();
+            if (!(arguments instanceof TupleExpression)) {
+                return false;
+            }
+            List<Expression> expressions = ((TupleExpression) arguments).getExpressions();
+            if (expressions.isEmpty()) {
+                return false;
+            }
+
+            return hasAllowedParamsAt(expressions, 2) || hasAllowedParamsAt(expressions, 3);
+        }
+
+        private boolean hasAllowedParamsAt(List<Expression> expressions, int allowedParamsIndex) {
+            if (allowedParamsIndex >= expressions.size()) {
+                return false;
+            }
+            Expression allowedParams = expressions.get(allowedParamsIndex);
+            if (allowedParams instanceof ConstantExpression || allowedParams instanceof MapExpression) {
+                return false;
+            }
+            if (allowedParams instanceof VariableExpression) {
+                return isAllowedParamsVariable((VariableExpression) allowedParams);
+            }
+            return true;
+        }
+
+        private boolean isAllowedParamsVariable(VariableExpression allowedParams) {
+            String variableName = allowedParams.getName();
+            if ("params".equals(variableName) || "request".equals(variableName)) {
+                return false;
+            }
+
+            ClassNode variableType = allowedParams.getType();
+            if (variableType == null || OBJECT_CLASS.equals(variableType) || ClassHelper.OBJECT_TYPE.equals(variableType)) {
+                return true;
+            }
+            return acceptsAllowedParamsType(variableType, ClassHelper.LIST_TYPE) || acceptsAllowedParamsType(variableType, new ClassNode(Collection.class));
+        }
+
+        private boolean acceptsAllowedParamsType(ClassNode variableType, ClassNode allowedParamsType) {
+            return variableType.equals(allowedParamsType) || variableType.isDerivedFrom(allowedParamsType) || variableType.implementsInterface(allowedParamsType);
+        }
+    }
+
     private void processMethods(ClassNode classNode, SourceUnit source,
             GeneratorContext context) {
 
diff --git a/grails-controllers/src/test/groovy/org/grails/compiler/web/ControllerActionTransformerCompilationErrorsSpec.groovy b/grails-controllers/src/test/groovy/org/grails/compiler/web/ControllerActionTransformerCompilationErrorsSpec.groovy
index ccca958f22..397ff29386 100644
--- a/grails-controllers/src/test/groovy/org/grails/compiler/web/ControllerActionTransformerCompilationErrorsSpec.groovy
+++ b/grails-controllers/src/test/groovy/org/grails/compiler/web/ControllerActionTransformerCompilationErrorsSpec.groovy
@@ -65,4 +65,426 @@ class ControllerActionTransformerCompilationErrorsSpec extends Specification {
             MultipleCompilationErrorsException e = thrown()
             e.message.contains 'Parameter [i] to method [methodAction] has default value [42].  Default parameter values are not allowed in controller action methods.'
     }
+
+    void 'Test secureBindData requires explicit allowed params'() {
+        when: 'A controller calls secureBindData without an allowed params list'
+            gcl.parseClass('''
+            class TestController {
+                def save() {
+                    def target = new Person()
+                    secureBindData(target, params)
+                }
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            MultipleCompilationErrorsException e = thrown()
+            e.message.contains 'secureBindData requires an explicit allowedParams list. Use secureBindData(target, params, allowedParams).'
+    }
+
+    void 'Test secureBindData requires explicit allowed params in controller trait'() {
+        when: 'A controller trait calls secureBindData without an allowed params list'
+            gcl.parseClass('''
+            trait SecureBindingActions {
+                def save() {
+                    def target = new Person()
+                    secureBindData(target, params)
+                }
+            }
+
+            class TraitSecureBindingController implements SecureBindingActions {
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            MultipleCompilationErrorsException e = thrown()
+            e.message.contains 'secureBindData requires an explicit allowedParams list. Use secureBindData(target, params, allowedParams).'
+    }
+
+    void 'Test secureBindData requires explicit allowed params in controller trait helper'() {
+        when: 'A controller trait action delegates to a helper that calls secureBindData without an allowed params list'
+            gcl.parseClass('''
+            trait SecureBindingHelperActions {
+                def save() {
+                    bindWithoutAllowlist()
+                }
+
+                private bindWithoutAllowlist() {
+                    def target = new Person()
+                    secureBindData(target, params)
+                }
+            }
+
+            class TraitHelperSecureBindingController implements SecureBindingHelperActions {
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            MultipleCompilationErrorsException e = thrown()
+            e.message.contains 'secureBindData requires an explicit allowedParams list. Use secureBindData(target, params, allowedParams).'
+    }
+
+    void 'Test controller trait may declare a secureBindData helper method'() {
+        when: 'A controller trait declares its own secureBindData helper method'
+            gcl.parseClass('''
+            trait HelperSecureBindingActions {
+                def save() {
+                    def target = new Person()
+                    secureBindData(target, params)
+                }
+
+                def secureBindData(Object target, Object source) {
+                    target.name = source.name
+                }
+            }
+
+            class HelperTraitSecureBindingController implements HelperSecureBindingActions {
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            noExceptionThrown()
+    }
+
+    void 'Test secureBindData requires explicit allowed params in inherited controller action'() {
+        when: 'A controller inherits an action that calls secureBindData without an allowed params list'
+            gcl.parseClass('''
+            class SecureBindingBase {
+                def save() {
+                    def target = new Person()
+                    secureBindData(target, params)
+                }
+            }
+
+            class InheritedSecureBindingController extends SecureBindingBase {
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            MultipleCompilationErrorsException e = thrown()
+            e.message.contains 'secureBindData requires an explicit allowedParams list. Use secureBindData(target, params, allowedParams).'
+    }
+
+    void 'Test controller may declare a secureBindData helper method'() {
+        when: 'A controller declares its own secureBindData helper method'
+            gcl.parseClass('''
+            class HelperSecureBindingController {
+                def save() {
+                    def target = new Person()
+                    secureBindData(target, params)
+                }
+
+                private secureBindData(Object target, Object source) {
+                    target.name = source.name
+                }
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            noExceptionThrown()
+    }
+
+    void 'Test controller may inherit a secureBindData helper method'() {
+        when: 'A controller inherits its own secureBindData helper method'
+            gcl.parseClass('''
+            class HelperSecureBindingBase {
+                protected secureBindData(Object target, Object source) {
+                    target.name = source.name
+                }
+            }
+
+            class InheritedHelperSecureBindingController extends HelperSecureBindingBase {
+                def save() {
+                    def target = new Person()
+                    secureBindData(target, params)
+                }
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            noExceptionThrown()
+    }
+
+    void 'Test inherited action may call private secureBindData helper declared in same class'() {
+        when: 'A superclass action calls its own private helper method'
+            gcl.parseClass('''
+            class PrivateActionHelperSecureBindingBase {
+                def save() {
+                    def target = new Person()
+                    secureBindData(target, params)
+                }
+
+                private secureBindData(Object target, Object source) {
+                    target.name = source.name
+                }
+            }
+
+            class PrivateActionHelperSecureBindingController extends PrivateActionHelperSecureBindingBase {
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            noExceptionThrown()
+    }
+
+    void 'Test inherited action may call concrete controller secureBindData helper method'() {
+        when: 'An inherited action calls a helper method declared by the concrete controller'
+            gcl.parseClass('''
+            class ConcreteHelperSecureBindingBase {
+                def save() {
+                    def target = new Person()
+                    secureBindData(target, params)
+                }
+            }
+
+            class ConcreteHelperSecureBindingController extends ConcreteHelperSecureBindingBase {
+                protected secureBindData(Object target, Object source) {
+                    target.name = source.name
+                }
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            noExceptionThrown()
+    }
+
+    void 'Test controller may declare a secureBindData helper method with params argument'() {
+        when: 'A helper can accept the dynamic params object as a Map argument'
+            gcl.parseClass('''
+            class SpecificHelperSecureBindingController {
+                def save() {
+                    def target = new Person()
+                    secureBindData(target, params)
+                }
+
+                protected secureBindData(Object target, Map source) {
+                    target.name = source.name
+                }
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            noExceptionThrown()
+    }
+
+    void 'Test Object typed target with narrowed secureBindData helper still requires framework validation'() {
+        when: 'A narrowed helper might not accept the runtime target value'
+            gcl.parseClass('''
+            class DynamicTargetHelperSecureBindingController {
+                def save() {
+                    Object target = new Person()
+                    secureBindData(target, params)
+                }
+
+                protected secureBindData(Person target, Map source) {
+                    target.name = source.name
+                }
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            MultipleCompilationErrorsException e = thrown()
+            e.message.contains 'secureBindData requires an explicit allowedParams list. Use secureBindData(target, params, allowedParams).'
+    }
+
+    void 'Test private inherited secureBindData helper does not suppress framework validation'() {
+        when: 'A private inherited helper is not visible to the controller action call'
+            gcl.parseClass('''
+            class PrivateHelperSecureBindingBase {
+                private secureBindData(Object target, Object source) {
+                    target.name = source.name
+                }
+            }
+
+            class PrivateInheritedHelperSecureBindingController extends PrivateHelperSecureBindingBase {
+                def save() {
+                    def target = new Person()
+                    secureBindData(target, params)
+                }
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            MultipleCompilationErrorsException e = thrown()
+            e.message.contains 'secureBindData requires an explicit allowedParams list. Use secureBindData(target, params, allowedParams).'
+    }
+
+    void 'Test incompatible secureBindData helper signature does not suppress framework validation'() {
+        when: 'A helper with the same arity cannot accept the controller action call arguments'
+            gcl.parseClass('''
+            class IncompatibleHelperSecureBindingController {
+                def save() {
+                    def target = new Person()
+                    secureBindData(target, params)
+                }
+
+                private secureBindData(String target, String source) {
+                    target + source
+                }
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            MultipleCompilationErrorsException e = thrown()
+            e.message.contains 'secureBindData requires an explicit allowedParams list. Use secureBindData(target, params, allowedParams).'
+    }
+
+    void 'Test secureBindData accepts explicit allowed params forms'() {
+        when: 'A controller calls secureBindData with explicit allowed params lists'
+            gcl.parseClass('''
+            class ExplicitSecureBindingController {
+                def bindParams() {
+                    def target = new Person()
+                    secureBindData(target, params, ['name'])
+                }
+
+                def bindParamsWithOptions() {
+                    def target = new Person()
+                    secureBindData([nullMissing: true], target, params, ['name'])
+                }
+
+                def bindParamsWithVariableOptions() {
+                    def target = new Person()
+                    def options = [nullMissing: true]
+                    secureBindData(options, target, params, ['name'])
+                }
+
+                def bindParamsWithPrefix() {
+                    def target = new Person()
+                    secureBindData(target, params, ['name'], 'person')
+                }
+
+                def bindParamsWithVariableAllowedParams() {
+                    def target = new Person()
+                    List allowedParams = ['name']
+                    secureBindData(target, params, allowedParams)
+                }
+
+                def bindParamsWithCollectionAllowedParams() {
+                    def target = new Person()
+                    Collection allowedParams = ['name']
+                    secureBindData(target, params, allowedParams)
+                }
+
+                def bindCollection() {
+                    def people = []
+                    secureBindData(Person, people, request, ['name'])
+                }
+
+                def bindCollectionWithVariableTargetType() {
+                    def people = []
+                    def targetType = Person
+                    secureBindData(targetType, people, request, ['name'])
+                }
+
+                def bindMapTarget() {
+                    secureBindData([:], params, ['name'])
+                }
+
+                def bindMapTargetWithNullMissingKey() {
+                    secureBindData([nullMissing: true], params, ['name'])
+                }
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            noExceptionThrown()
+    }
+
+    void 'Test secureBindData options map still requires explicit allowed params'() {
+        when: 'A controller calls secureBindData with options but without an allowed params list'
+            gcl.parseClass('''
+            class OptionsWithoutAllowedParamsController {
+                def save() {
+                    def target = new Person()
+                    secureBindData([nullMissing: true], target, params)
+                }
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            MultipleCompilationErrorsException e = thrown()
+            e.message.contains 'secureBindData requires an explicit allowedParams list. Use secureBindData(target, params, allowedParams).'
+    }
+
+    void 'Test secureBindData rejects non-collection allowed params variable'() {
+        when: 'A controller passes a non-collection variable where allowed params are required'
+            gcl.parseClass('''
+            class NonListAllowedParamsController {
+                def save() {
+                    def target = new Person()
+                    String filter = 'name'
+                    secureBindData(target, params, filter)
+                }
+            }
+
+            class Person {
+                String name
+            }
+            ''')
+
+        then:
+            MultipleCompilationErrorsException e = thrown()
+            e.message.contains 'secureBindData requires an explicit allowedParams list. Use secureBindData(target, params, allowedParams).'
+    }
 }
diff --git a/grails-doc/src/en/guide/index.adoc b/grails-doc/src/en/guide/index.adoc
index 2e7fa47e86..47e90e3622 100644
--- a/grails-doc/src/en/guide/index.adoc
+++ b/grails-doc/src/en/guide/index.adoc
@@ -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
 
@@ -2542,4 +2547,3 @@ include::ref/Tags/uploadForm.adoc[]
 ==== while
 
 include::ref/Tags/while.adoc[]
-
diff --git a/grails-doc/src/en/guide/reference.adoc b/grails-doc/src/en/guide/reference.adoc
index e69395e216..00e2b92110 100644
--- a/grails-doc/src/en/guide/reference.adoc
+++ b/grails-doc/src/en/guide/reference.adoc
@@ -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
 
diff --git a/grails-doc/src/en/guide/theWebLayer/controllers/dataBinding.adoc b/grails-doc/src/en/guide/theWebLayer/controllers/dataBinding.adoc
index 017661bfb4..97a556d5c1 100644
--- a/grails-doc/src/en/guide/theWebLayer/controllers/dataBinding.adoc
+++ b/grails-doc/src/en/guide/theWebLayer/controllers/dataBinding.adoc
@@ -1087,4 +1087,46 @@ 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'])
+----
+
+Use nested property paths when allowing nested binding:
+
+[source,groovy]
+----
+def p = new Person()
+secureBindData(p, params, ['firstName', 'homeAddress.street', 'homeAddress.city'])
+----
+
+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.
diff --git a/grails-doc/src/en/ref/Controllers/bindData.adoc b/grails-doc/src/en/ref/Controllers/bindData.adoc
index a809e63528..075bf4e56e 100644
--- a/grails-doc/src/en/ref/Controllers/bindData.adoc
+++ b/grails-doc/src/en/ref/Controllers/bindData.adoc
@@ -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.
diff --git a/grails-doc/src/en/ref/Controllers/secureBindData.adoc b/grails-doc/src/en/ref/Controllers/secureBindData.adoc
new file mode 100644
index 0000000000..3c44fda0ae
--- /dev/null
+++ b/grails-doc/src/en/ref/Controllers/secureBindData.adoc
@@ -0,0 +1,68 @@
+////
+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'])
+
+// 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(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.
+
+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.
+
+Refer to the section on link:{guidePath}theWebLayer.html#dataBinding[data binding] in the user guide for more information.