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

lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/main by this push:
     new e83487f0e WW-5627 Gate CookieInterceptor through ParameterAuthorizer 
(#1681)
e83487f0e is described below

commit e83487f0e19f4000cf46a88fc6f06bd549ad9d7b
Author: Lukasz Lenart <[email protected]>
AuthorDate: Mon May 18 13:52:17 2026 +0200

    WW-5627 Gate CookieInterceptor through ParameterAuthorizer (#1681)
    
    * WW-5627 add ParameterAllowlister interface and 
STRUTS_PARAMETER_ALLOWLISTER constant
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * WW-5627 add OgnlParameterAllowlister default implementation
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * WW-5627 register ParameterAllowlister bean in struts-default DI
    
    * WW-5627 delegate ParametersInterceptor OGNL allowlisting to 
OgnlParameterAllowlister
    
    Also register ParameterAllowlister in DefaultConfiguration bootstrap
    factories so it is available in test containers (parallel to how
    ParameterAuthorizer was already registered there).
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * WW-5627 test(cookie): failing test for unannotated setter skip
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
    
    * WW-5627 gate CookieInterceptor cookie injection through 
ParameterAuthorizer
    
    Adds a 5-arg `populateCookieValueIntoStack(name, value, map, stack, 
action)` hook
    that runs cookie writes through `ParameterAuthorizer.isAuthorized` and 
primes
    `ThreadAllowlist` via `ParameterAllowlister` for nested paths, then 
delegates
    to the legacy 4-arg form. The 4-arg form is `@Deprecated(since="7.2.0")` but
    its body is unchanged, so existing subclass overrides automatically receive
    only authorized cookies. Default-config behavior is preserved because the
    authorizer short-circuits when `requireAnnotations=false`.
    
    Existing `CookieInterceptorTest` instantiates `new CookieInterceptor()` 
rather
    than going through the container, leaving the new injected fields null. 
Wires
    explicit pass-through lambdas through a `disableAuthorizationGate(...)` 
helper
    so those tests continue to exercise default-config behavior.
    
    * WW-5627 cover CookieInterceptor authorization matrix in 
CookieInterceptorAnnotationTest
    
    * WW-5627 docs(cookie): document new 5-arg extension hook and deprecation
    
    * WW-5627 wire OgnlParameterAllowlister in StrutsParameterAnnotationTest 
fixture
    
    * WW-5627 address SonarCloud findings on PR #1681
    
    - S1948: mark transient on the new ParameterAuthorizer/ParameterAllowlister
      fields in CookieInterceptor and ParametersInterceptor (the host classes
      are Serializable; the injected services are not).
    - S1874: suppress the deprecation warning on the new 5-arg
      populateCookieValueIntoStack — the delegation to the deprecated 4-arg
      form is the contract that lets existing subclass overrides participate.
    - S3776: extract `allowlistViaPropertyDescriptor` and
      `allowlistViaPublicField` from 
`OgnlParameterAllowlister.allowlistAuthorizedPath`
      to drop cognitive complexity below the threshold.
    - S1068: remove the unused `mapping` test fixture field.
    
    * WW-5627 clarify ParameterAllowlister contract and tidy 
ParametersInterceptor
    
    Rename `ParameterAllowlister#allowlistAuthorizedPath` to 
`primeAllowlistForPath`
    to make the contract explicit: the SAM is a side-effect-only priming hook 
that
    runs after `ParameterAuthorizer#isAuthorized` has already decided. A no-op
    return means "no priming needed or possible", never "rejected". The 
interface
    name stays channel-agnostic; only the impl class 
(`OgnlParameterAllowlister`)
    binds the priming to OGNL's `ThreadAllowlist`.
    
    Add a `LOG.debug` in `OgnlParameterAllowlister` for the case where 
authorization
    passed but no `@StrutsParameter` could be located on the root property
    (e.g. `ModelDriven` models without per-property annotations) so the
    authorize-vs-prime gap is observable instead of surfacing later as an opaque
    OGNL traversal failure.
    
    Drop the dead `performOgnlAllowlisting` pass-through and its unused 
`paramDepth`
    parameter from `ParametersInterceptor` — the depth check is already enforced
    inside `OgnlParameterAllowlister.primeAllowlistForPath`, so the outer guard 
was
    a redundant computation.
    
    No behavior change.
    
    ---------
    
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
---
 .../java/org/apache/struts2/StrutsConstants.java   |   8 +
 .../config/StrutsBeanSelectionProvider.java        |   2 +
 .../struts2/config/impl/DefaultConfiguration.java  |   3 +
 .../struts2/interceptor/CookieInterceptor.java     |  63 +++++-
 .../parameter/OgnlParameterAllowlister.java        | 192 ++++++++++++++++++
 .../parameter/ParameterAllowlister.java            |  44 +++++
 .../parameter/ParametersInterceptor.java           |  56 +-----
 core/src/main/resources/struts-beans.xml           |   3 +
 .../CookieInterceptorAnnotationTest.java           | 219 +++++++++++++++++++++
 .../struts2/interceptor/CookieInterceptorTest.java |  18 ++
 .../parameter/OgnlParameterAllowlisterTest.java    | 154 +++++++++++++++
 .../parameter/StrutsParameterAnnotationTest.java   |   6 +
 12 files changed, 714 insertions(+), 54 deletions(-)

diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java 
b/core/src/main/java/org/apache/struts2/StrutsConstants.java
index eb925b422..13fc2d3d2 100644
--- a/core/src/main/java/org/apache/struts2/StrutsConstants.java
+++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java
@@ -558,6 +558,14 @@ public final class StrutsConstants {
      */
     public static final String STRUTS_PARAMETER_AUTHORIZER = 
"struts.parameterAuthorizer";
 
+    /**
+     * The {@link 
org.apache.struts2.interceptor.parameter.ParameterAllowlister} implementation 
class.
+     * Override to provide a custom allowlister for non-OGNL parameter targets.
+     *
+     * @since 7.2.0
+     */
+    public static final String STRUTS_PARAMETER_ALLOWLISTER = 
"struts.parameterAllowlister";
+
     /**
      * Enables evaluation of OGNL expressions
      *
diff --git 
a/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java 
b/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
index f169b67f1..e3f632eda 100644
--- 
a/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java
@@ -73,6 +73,7 @@ import org.apache.struts2.url.UrlDecoder;
 import org.apache.struts2.url.UrlEncoder;
 import org.apache.struts2.util.ContentTypeMatcher;
 import org.apache.struts2.util.PatternMatcher;
+import org.apache.struts2.interceptor.parameter.ParameterAllowlister;
 import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
 import org.apache.struts2.util.ProxyService;
 import org.apache.struts2.util.TextParser;
@@ -448,6 +449,7 @@ public class StrutsBeanSelectionProvider extends 
AbstractBeanSelectionProvider {
         alias(ProxyCacheFactory.class, 
StrutsConstants.STRUTS_PROXY_CACHE_FACTORY, builder, props, Scope.SINGLETON);
         alias(ProxyService.class, StrutsConstants.STRUTS_PROXYSERVICE, 
builder, props, Scope.SINGLETON);
         alias(ParameterAuthorizer.class, 
StrutsConstants.STRUTS_PARAMETER_AUTHORIZER, builder, props, Scope.SINGLETON);
+        alias(ParameterAllowlister.class, 
StrutsConstants.STRUTS_PARAMETER_ALLOWLISTER, builder, props, Scope.SINGLETON);
 
         alias(SecurityMemberAccess.class, 
StrutsConstants.STRUTS_MEMBER_ACCESS, builder, props, Scope.PROTOTYPE);
         alias(OgnlGuard.class, StrutsConstants.STRUTS_OGNL_GUARD, builder, 
props, Scope.SINGLETON);
diff --git 
a/core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java 
b/core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java
index 9eb009592..dcf4f1602 100644
--- 
a/core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java
+++ 
b/core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java
@@ -92,6 +92,8 @@ import org.apache.struts2.ognl.SecurityMemberAccess;
 import org.apache.struts2.ognl.accessor.CompoundRootAccessor;
 import org.apache.struts2.ognl.accessor.RootAccessor;
 import org.apache.struts2.ognl.accessor.XWorkMethodAccessor;
+import org.apache.struts2.interceptor.parameter.OgnlParameterAllowlister;
+import org.apache.struts2.interceptor.parameter.ParameterAllowlister;
 import org.apache.struts2.interceptor.parameter.StrutsParameterAuthorizer;
 import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
 import org.apache.struts2.util.StrutsProxyService;
@@ -409,6 +411,7 @@ public class DefaultConfiguration implements Configuration {
                 .factory(ProxyCacheFactory.class, 
StrutsProxyCacheFactory.class, Scope.SINGLETON)
                 .factory(ProxyService.class, StrutsProxyService.class, 
Scope.SINGLETON)
                 .factory(ParameterAuthorizer.class, 
StrutsParameterAuthorizer.class, Scope.SINGLETON)
+                .factory(ParameterAllowlister.class, 
OgnlParameterAllowlister.class, Scope.SINGLETON)
                 .factory(OgnlUtil.class, Scope.SINGLETON)
                 .factory(SecurityMemberAccess.class, Scope.PROTOTYPE)
                 .factory(OgnlGuard.class, StrutsOgnlGuard.class, 
Scope.SINGLETON)
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/CookieInterceptor.java 
b/core/src/main/java/org/apache/struts2/interceptor/CookieInterceptor.java
index 6ca7ecb0a..4d9685543 100644
--- a/core/src/main/java/org/apache/struts2/interceptor/CookieInterceptor.java
+++ b/core/src/main/java/org/apache/struts2/interceptor/CookieInterceptor.java
@@ -26,6 +26,8 @@ import org.apache.struts2.ActionInvocation;
 import org.apache.struts2.ServletActionContext;
 import org.apache.struts2.action.CookiesAware;
 import org.apache.struts2.inject.Inject;
+import org.apache.struts2.interceptor.parameter.ParameterAllowlister;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
 import org.apache.struts2.security.AcceptedPatternsChecker;
 import org.apache.struts2.security.ExcludedPatternsChecker;
 import org.apache.struts2.util.TextParseUtil;
@@ -99,8 +101,16 @@ import java.util.Set;
  *
  * <ul>
  *     <li>
- *         populateCookieValueIntoStack - this method will decide if this 
cookie value is qualified
- *         to be populated into the value stack (hence into the action itself)
+ *         populateCookieValueIntoStack(name, value, map, stack, action) - the 
preferred extension point
+ *         since 7.2.0. The default implementation gates the cookie write 
through
+ *         {@link 
org.apache.struts2.interceptor.parameter.ParameterAuthorizer} and primes the 
OGNL allowlist via
+ *         {@link 
org.apache.struts2.interceptor.parameter.ParameterAllowlister} before 
delegating to the legacy
+ *         4-arg {@code populateCookieValueIntoStack}. Override here to 
customize the authorization behavior itself.
+ *     </li>
+ *     <li>
+ *         populateCookieValueIntoStack(name, value, map, stack) - 
<em>deprecated since 7.2.0</em>. The legacy
+ *         hook that performs the actual {@code stack.setValue}. Existing 
overrides continue to work and
+ *         automatically receive only authorized cookies via the 5-arg default.
  *     </li>
  *     <li>
  *         injectIntoCookiesAwareAction - this method will inject selected 
cookies (as a java.util.Map)
@@ -187,6 +197,8 @@ public class CookieInterceptor extends AbstractInterceptor {
 
     private ExcludedPatternsChecker excludedPatternsChecker;
     private AcceptedPatternsChecker acceptedPatternsChecker;
+    private transient ParameterAuthorizer parameterAuthorizer;
+    private transient ParameterAllowlister parameterAllowlister;
 
     @Inject
     public void setExcludedPatternsChecker(ExcludedPatternsChecker 
excludedPatternsChecker) {
@@ -199,6 +211,16 @@ public class CookieInterceptor extends AbstractInterceptor 
{
         this.acceptedPatternsChecker.setAcceptedPatterns(ACCEPTED_PATTERN);
     }
 
+    @Inject
+    public void setParameterAuthorizer(ParameterAuthorizer 
parameterAuthorizer) {
+        this.parameterAuthorizer = parameterAuthorizer;
+    }
+
+    @Inject
+    public void setParameterAllowlister(ParameterAllowlister 
parameterAllowlister) {
+        this.parameterAllowlister = parameterAllowlister;
+    }
+
     /**
      * @param cookiesName the <code>cookiesName</code> which if matched will 
allow the cookie
      * to be injected into action, could be comma-separated string.
@@ -234,6 +256,8 @@ public class CookieInterceptor extends AbstractInterceptor {
     public String intercept(ActionInvocation invocation) throws Exception {
         LOG.debug("start interception");
 
+        final Object action = invocation.getAction();
+
         // contains selected cookies
         final Map<String, String> cookiesMap = new LinkedHashMap<>();
 
@@ -248,9 +272,9 @@ public class CookieInterceptor extends AbstractInterceptor {
                 if (isAcceptableName(name)) {
                     if (cookiesNameSet.contains("*")) {
                         LOG.debug("Contains cookie name [*] in configured 
cookies name set, cookie with name [{}] with value [{}] will be injected", 
name, value);
-                        populateCookieValueIntoStack(name, value, cookiesMap, 
stack);
+                        populateCookieValueIntoStack(name, value, cookiesMap, 
stack, action);
                     } else if (cookiesNameSet.contains(cookie.getName())) {
-                        populateCookieValueIntoStack(name, value, cookiesMap, 
stack);
+                        populateCookieValueIntoStack(name, value, cookiesMap, 
stack, action);
                     }
                 } else {
                     LOG.warn("Cookie name [{}] with value [{}] was rejected!", 
name, value);
@@ -259,7 +283,7 @@ public class CookieInterceptor extends AbstractInterceptor {
         }
 
         // inject the cookiesMap, even if we don't have any cookies
-        injectIntoCookiesAwareAction(invocation.getAction(), cookiesMap);
+        injectIntoCookiesAwareAction(action, cookiesMap);
 
         return invocation.invoke();
     }
@@ -314,6 +338,30 @@ public class CookieInterceptor extends AbstractInterceptor 
{
         return false;
     }
 
+    /**
+     * Authorizes the cookie against {@link ParameterAuthorizer}, primes OGNL 
allowlist for any nested path via
+     * {@link ParameterAllowlister}, then delegates to the legacy {@link 
#populateCookieValueIntoStack(String, String,
+     * Map, ValueStack)} hook so existing subclass overrides continue to 
participate. Override this method to customize
+     * the authorization behavior itself.
+     *
+     * @param cookieName  cookie name (potentially an OGNL path; {@code 
ACCEPTED_PATTERN} restricts the character set)
+     * @param cookieValue cookie value
+     * @param cookiesMap  map of cookies populated for {@link 
org.apache.struts2.action.CookiesAware}
+     * @param stack       current request value stack
+     * @param action      the action instance from {@link 
ActionInvocation#getAction()}; used for {@code @StrutsParameter} target 
resolution
+     * @since 7.2.0
+     */
+    @SuppressWarnings("deprecation") // intentional: delegating to the 
deprecated 4-arg form is the contract that lets existing subclass overrides 
participate
+    protected void populateCookieValueIntoStack(String cookieName, String 
cookieValue, Map<String, String> cookiesMap, ValueStack stack, Object action) {
+        Object target = parameterAuthorizer.resolveTarget(action);
+        if (!parameterAuthorizer.isAuthorized(cookieName, target, action)) {
+            LOG.debug("Cookie [{}] rejected by @StrutsParameter authorization 
on target [{}]", cookieName, target.getClass().getSimpleName());
+            return;
+        }
+        parameterAllowlister.primeAllowlistForPath(cookieName, target);
+        populateCookieValueIntoStack(cookieName, cookieValue, cookiesMap, 
stack);
+    }
+
     /**
      * Hook that populate cookie value into value stack (hence the action)
      * if the criteria is satisfied (if the cookie value matches with those 
configured).
@@ -322,7 +370,12 @@ public class CookieInterceptor extends AbstractInterceptor 
{
      * @param cookieValue cookie value
      * @param cookiesMap map of cookies
      * @param stack value stack
+     * @deprecated since 7.2.0. Override
+     * {@link #populateCookieValueIntoStack(String, String, Map, ValueStack, 
Object)} instead so cookie writes are
+     * authorized by {@link ParameterAuthorizer}. The default 5-arg 
implementation calls this method after the
+     * authorization gate, so existing overrides continue to receive only 
authorized cookies.
      */
+    @Deprecated(since = "7.2.0")
     protected void populateCookieValueIntoStack(String cookieName, String 
cookieValue, Map<String, String> cookiesMap, ValueStack stack) {
         if (cookiesValueSet.isEmpty() || cookiesValueSet.contains("*")) {
             // If the interceptor is configured to accept any cookie value
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/parameter/OgnlParameterAllowlister.java
 
b/core/src/main/java/org/apache/struts2/interceptor/parameter/OgnlParameterAllowlister.java
new file mode 100644
index 000000000..0a24d8577
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/parameter/OgnlParameterAllowlister.java
@@ -0,0 +1,192 @@
+/*
+ * 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
+ *
+ *  http://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.
+ */
+package org.apache.struts2.interceptor.parameter;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.inject.Inject;
+import org.apache.struts2.ognl.OgnlUtil;
+import org.apache.struts2.ognl.ThreadAllowlist;
+import org.apache.struts2.util.ProxyService;
+
+import java.beans.BeanInfo;
+import java.beans.IntrospectionException;
+import java.beans.PropertyDescriptor;
+import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.Arrays;
+import java.util.Optional;
+
+import static org.apache.commons.lang3.StringUtils.indexOfAny;
+import static 
org.apache.struts2.security.DefaultAcceptedPatternsChecker.NESTING_CHARS;
+import static 
org.apache.struts2.security.DefaultAcceptedPatternsChecker.NESTING_CHARS_STR;
+
+/**
+ * Default {@link ParameterAllowlister}. Registers the root property's class 
(and generic type args for {@code depth >= 2})
+ * into the OGNL {@link ThreadAllowlist} so OGNL may introspect and traverse a 
nested path on the value stack. Logic is
+ * extracted verbatim from {@code 
ParametersInterceptor.performOgnlAllowlisting} so the OGNL parameter and cookie
+ * channels share a single implementation.
+ *
+ * <p>No-ops when:
+ * <ul>
+ *   <li>{@code paramDepth == 0} — shallow setter; OGNL does not need to 
traverse</li>
+ *   <li>the root property has no {@code @StrutsParameter} annotation 
reachable via {@link java.beans.PropertyDescriptor}
+ *       or as a public field (e.g. a {@code ModelDriven} model whose 
properties are not individually annotated). A
+ *       {@code LOG.debug} surfaces this case so the gap between authorization 
and OGNL traversal is observable.</li>
+ * </ul>
+ *
+ * @since 7.2.0
+ */
+public class OgnlParameterAllowlister implements ParameterAllowlister {
+
+    private static final Logger LOG = 
LogManager.getLogger(OgnlParameterAllowlister.class);
+
+    private OgnlUtil ognlUtil;
+    private ProxyService proxyService;
+    private ThreadAllowlist threadAllowlist;
+
+    @Inject
+    public void setOgnlUtil(OgnlUtil ognlUtil) {
+        this.ognlUtil = ognlUtil;
+    }
+
+    @Inject
+    public void setProxyService(ProxyService proxyService) {
+        this.proxyService = proxyService;
+    }
+
+    @Inject
+    public void setThreadAllowlist(ThreadAllowlist threadAllowlist) {
+        this.threadAllowlist = threadAllowlist;
+    }
+
+    @Override
+    public void primeAllowlistForPath(String parameterName, Object target) {
+        if (parameterName == null || parameterName.isEmpty() || target == 
null) {
+            return;
+        }
+        long paramDepth = parameterName.codePoints().mapToObj(c -> (char) 
c).filter(NESTING_CHARS::contains).count();
+        if (paramDepth == 0) {
+            return;
+        }
+
+        int nestingIndex = indexOfAny(parameterName, NESTING_CHARS_STR);
+        String rootProperty = nestingIndex == -1 ? parameterName : 
parameterName.substring(0, nestingIndex);
+        String normalisedRootProperty = 
Character.toLowerCase(rootProperty.charAt(0)) + rootProperty.substring(1);
+
+        if (allowlistViaPropertyDescriptor(target, normalisedRootProperty, 
paramDepth)) {
+            return;
+        }
+        if (allowlistViaPublicField(target, normalisedRootProperty, 
paramDepth)) {
+            return;
+        }
+        // Authorization passed but no @StrutsParameter on the root property — 
e.g. ModelDriven model with no
+        // per-property annotations. OGNL won't be able to walk this nested 
path; surface the gap in logs.
+        LOG.debug("Parameter [{}] authorized but no @StrutsParameter on root 
property [{}] of [{}]; "
+                + "OGNL allowlist not primed and nested traversal may be 
blocked",
+                parameterName, normalisedRootProperty, 
ultimateClass(target).getSimpleName());
+    }
+
+    private boolean allowlistViaPropertyDescriptor(Object target, String 
rootProperty, long paramDepth) {
+        BeanInfo beanInfo = getBeanInfo(target);
+        if (beanInfo == null) {
+            return false;
+        }
+        Optional<PropertyDescriptor> propDescOpt = 
Arrays.stream(beanInfo.getPropertyDescriptors())
+                .filter(desc -> 
desc.getName().equals(rootProperty)).findFirst();
+        if (propDescOpt.isEmpty()) {
+            return false;
+        }
+        PropertyDescriptor propDesc = propDescOpt.get();
+        Method relevantMethod = propDesc.getReadMethod();
+        if (relevantMethod == null || 
getPermittedInjectionDepth(relevantMethod) < paramDepth) {
+            return false;
+        }
+        allowlistClass(propDesc.getPropertyType());
+        if (paramDepth >= 2) {
+            
allowlistParameterizedTypeArg(relevantMethod.getGenericReturnType());
+        }
+        return true;
+    }
+
+    private boolean allowlistViaPublicField(Object target, String 
rootProperty, long paramDepth) {
+        Class<?> targetClass = ultimateClass(target);
+        Field field;
+        try {
+            field = targetClass.getDeclaredField(rootProperty);
+        } catch (NoSuchFieldException e) {
+            return false;
+        }
+        if (!Modifier.isPublic(field.getModifiers()) || 
getPermittedInjectionDepth(field) < paramDepth) {
+            return false;
+        }
+        allowlistClass(field.getType());
+        if (paramDepth >= 2) {
+            allowlistParameterizedTypeArg(field.getGenericType());
+        }
+        return true;
+    }
+
+    private void allowlistClass(Class<?> clazz) {
+        threadAllowlist.allowClassHierarchy(clazz);
+    }
+
+    private void allowlistParameterizedTypeArg(Type genericType) {
+        if (!(genericType instanceof ParameterizedType pType)) {
+            return;
+        }
+        Type[] paramTypes = pType.getActualTypeArguments();
+        allowlistParamType(paramTypes[0]);
+        if (paramTypes.length > 1) {
+            allowlistParamType(paramTypes[1]);
+        }
+    }
+
+    private void allowlistParamType(Type paramType) {
+        if (paramType instanceof Class<?> clazz) {
+            allowlistClass(clazz);
+        }
+    }
+
+    private int getPermittedInjectionDepth(AnnotatedElement element) {
+        StrutsParameter annotation = 
element.getAnnotation(StrutsParameter.class);
+        return annotation == null ? -1 : annotation.depth();
+    }
+
+    private Class<?> ultimateClass(Object target) {
+        if (proxyService.isProxy(target)) {
+            return proxyService.ultimateTargetClass(target);
+        }
+        return target.getClass();
+    }
+
+    private BeanInfo getBeanInfo(Object target) {
+        Class<?> targetClass = ultimateClass(target);
+        try {
+            return ognlUtil.getBeanInfo(targetClass);
+        } catch (IntrospectionException e) {
+            LOG.warn("Error introspecting target {} for OGNL allowlisting", 
targetClass, e);
+            return null;
+        }
+    }
+}
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAllowlister.java
 
b/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAllowlister.java
new file mode 100644
index 000000000..846498c71
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAllowlister.java
@@ -0,0 +1,44 @@
+/*
+ * 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
+ *
+ *  http://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.
+ */
+package org.apache.struts2.interceptor.parameter;
+
+/**
+ * Primes channel-specific runtime state required for an already-authorized 
parameter path to be walked by the
+ * value-stack — for example, registering the path's classes into the OGNL 
{@link org.apache.struts2.ognl.ThreadAllowlist}
+ * so OGNL may traverse them. Separated from {@link ParameterAuthorizer} so 
the authorization decision can remain
+ * side-effect-free and reusable from non-OGNL channels (Jackson, Juneau).
+ *
+ * <p>Implementations MUST NOT repeat the authorization decision — that is 
owned by
+ * {@link ParameterAuthorizer#isAuthorized}. A no-op return (e.g. shallow 
paths, unannotated root) means "no priming
+ * needed or possible" and never "rejected": callers must not treat the 
absence of priming as a negative authorization
+ * signal.</p>
+ *
+ * @since 7.2.0
+ */
+public interface ParameterAllowlister {
+
+    /**
+     * Primes the channel-specific allowlist for an authorized parameter path. 
Side-effect-only; no return value
+     * because a no-op is a valid outcome (see class-level javadoc).
+     *
+     * @param parameterName the parameter name (e.g. {@code "user.role"}, 
{@code "items[0].name"})
+     * @param target        the object receiving the parameter value (the 
action, or the model for ModelDriven actions)
+     */
+    void primeAllowlistForPath(String parameterName, Object target);
+}
diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/parameter/ParametersInterceptor.java
 
b/core/src/main/java/org/apache/struts2/interceptor/parameter/ParametersInterceptor.java
index 5491b585f..365868a2b 100644
--- 
a/core/src/main/java/org/apache/struts2/interceptor/parameter/ParametersInterceptor.java
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/parameter/ParametersInterceptor.java
@@ -65,10 +65,7 @@ import java.util.regex.Pattern;
 import static java.lang.String.format;
 import static java.util.Collections.unmodifiableSet;
 import static java.util.stream.Collectors.joining;
-import static org.apache.commons.lang3.StringUtils.indexOfAny;
 import static org.apache.commons.lang3.StringUtils.normalizeSpace;
-import static 
org.apache.struts2.security.DefaultAcceptedPatternsChecker.NESTING_CHARS;
-import static 
org.apache.struts2.security.DefaultAcceptedPatternsChecker.NESTING_CHARS_STR;
 import static org.apache.struts2.util.DebugUtils.logWarningForFirstOccurrence;
 import static org.apache.struts2.util.DebugUtils.notifyDeveloperOfError;
 
@@ -100,6 +97,7 @@ public class ParametersInterceptor extends 
MethodFilterInterceptor {
     private Set<Pattern> excludedValuePatterns = null;
     private Set<Pattern> acceptedValuePatterns = null;
     private ParameterAuthorizer parameterAuthorizer;
+    private transient ParameterAllowlister parameterAllowlister;
 
     @Inject
     public void setValueStackFactory(ValueStackFactory valueStackFactory) {
@@ -126,6 +124,11 @@ public class ParametersInterceptor extends 
MethodFilterInterceptor {
         this.parameterAuthorizer = parameterAuthorizer;
     }
 
+    @Inject
+    public void setParameterAllowlister(ParameterAllowlister 
parameterAllowlister) {
+        this.parameterAllowlister = parameterAllowlister;
+    }
+
     @Inject(StrutsConstants.STRUTS_DEVMODE)
     public void setDevMode(String mode) {
         this.devMode = BooleanUtils.toBoolean(mode);
@@ -375,55 +378,10 @@ public class ParametersInterceptor extends 
MethodFilterInterceptor {
             return false;
         }
 
-        // OGNL-specific allowlisting: only needed for nested params (depth >= 
1)
-        long paramDepth = name.codePoints().mapToObj(c -> (char) 
c).filter(NESTING_CHARS::contains).count();
-        if (paramDepth >= 1) {
-            performOgnlAllowlisting(name, target, paramDepth);
-        }
+        parameterAllowlister.primeAllowlistForPath(name, target);
         return true;
     }
 
-    /**
-     * Performs OGNL ThreadAllowlist side effects for an authorized parameter. 
This is specific to OGNL-based parameter
-     * injection and must NOT be shared with other input channels (JSON, REST).
-     */
-    private void performOgnlAllowlisting(String name, Object target, long 
paramDepth) {
-        int nestingIndex = indexOfAny(name, NESTING_CHARS_STR);
-        String rootProperty = nestingIndex == -1 ? name : name.substring(0, 
nestingIndex);
-        String normalisedRootProperty = 
Character.toLowerCase(rootProperty.charAt(0)) + rootProperty.substring(1);
-
-        BeanInfo beanInfo = getBeanInfo(target);
-        if (beanInfo != null) {
-            Optional<PropertyDescriptor> propDescOpt = 
Arrays.stream(beanInfo.getPropertyDescriptors())
-                    .filter(desc -> 
desc.getName().equals(normalisedRootProperty)).findFirst();
-            if (propDescOpt.isPresent()) {
-                PropertyDescriptor propDesc = propDescOpt.get();
-                Method relevantMethod = paramDepth == 0 ? 
propDesc.getWriteMethod() : propDesc.getReadMethod();
-                if (relevantMethod != null && 
getPermittedInjectionDepth(relevantMethod) >= paramDepth) {
-                    allowlistClass(propDesc.getPropertyType());
-                    if (paramDepth >= 2) {
-                        allowlistReturnTypeIfParameterized(relevantMethod);
-                    }
-                    return;
-                }
-            }
-        }
-
-        // Fallback: check public field
-        Class<?> targetClass = ultimateClass(target);
-        try {
-            Field field = targetClass.getDeclaredField(normalisedRootProperty);
-            if (Modifier.isPublic(field.getModifiers()) && 
getPermittedInjectionDepth(field) >= paramDepth) {
-                allowlistClass(field.getType());
-                if (paramDepth >= 2) {
-                    allowlistFieldIfParameterized(field);
-                }
-            }
-        } catch (NoSuchFieldException e) {
-            // No field to allowlist
-        }
-    }
-
     /**
      * Note that we check for a public field last or only if there is no 
valid, annotated property descriptor. This is
      * because this check is likely to fail more often than not, as the 
relative use of public fields is low - so we
diff --git a/core/src/main/resources/struts-beans.xml 
b/core/src/main/resources/struts-beans.xml
index 232f0f4a4..21c4ec7e6 100644
--- a/core/src/main/resources/struts-beans.xml
+++ b/core/src/main/resources/struts-beans.xml
@@ -248,6 +248,9 @@
     <bean type="org.apache.struts2.interceptor.parameter.ParameterAuthorizer" 
name="struts"
           
class="org.apache.struts2.interceptor.parameter.StrutsParameterAuthorizer" 
scope="singleton"/>
 
+    <bean type="org.apache.struts2.interceptor.parameter.ParameterAllowlister" 
name="struts"
+          
class="org.apache.struts2.interceptor.parameter.OgnlParameterAllowlister" 
scope="singleton"/>
+
     <bean type="org.apache.struts2.url.QueryStringBuilder" 
name="strutsQueryStringBuilder"
           class="org.apache.struts2.url.StrutsQueryStringBuilder" 
scope="singleton"/>
     <bean type="org.apache.struts2.url.QueryStringParser" 
name="strutsQueryStringParser"
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/CookieInterceptorAnnotationTest.java
 
b/core/src/test/java/org/apache/struts2/interceptor/CookieInterceptorAnnotationTest.java
new file mode 100644
index 000000000..4d794ecbf
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/CookieInterceptorAnnotationTest.java
@@ -0,0 +1,219 @@
+/*
+ * 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
+ *
+ *  http://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.
+ */
+package org.apache.struts2.interceptor;
+
+import jakarta.servlet.http.Cookie;
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.ModelDriven;
+import org.apache.struts2.ServletActionContext;
+import org.apache.struts2.StrutsInternalTestCase;
+import org.apache.struts2.action.Action;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
+import org.apache.struts2.interceptor.parameter.StrutsParameter;
+import org.apache.struts2.interceptor.parameter.StrutsParameterAuthorizer;
+import org.apache.struts2.mock.MockActionInvocation;
+import org.apache.struts2.util.ValueStack;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class CookieInterceptorAnnotationTest extends StrutsInternalTestCase {
+
+    private CookieInterceptor interceptor;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        interceptor = container.inject(CookieInterceptor.class);
+        interceptor.setCookiesName("*");
+    }
+
+    @Override
+    protected void tearDown() throws Exception {
+        // Reset shared singleton state — flags flipped on the container's 
StrutsParameterAuthorizer
+        // would otherwise leak across tests in the same JVM run.
+        configureRequireAnnotations(false, false);
+        super.tearDown();
+    }
+
+    public void testRequireAnnotations_unannotatedSetter_isSkipped() throws 
Exception {
+        configureRequireAnnotations(true, false);
+        AnnotatedAction action = new AnnotatedAction();
+        invokeWithCookies(action, new Cookie("unannotated", "v"));
+
+        assertNull("unannotated setter must not be populated", 
action.getUnannotated());
+        
assertNull(ActionContext.getContext().getValueStack().findValue("unannotated"));
+    }
+
+    public void testRequireAnnotations_annotatedSetter_isInjected() throws 
Exception {
+        configureRequireAnnotations(true, false);
+        AnnotatedAction action = new AnnotatedAction();
+        invokeWithCookies(action, new Cookie("annotated", "v"));
+
+        assertEquals("v", action.getAnnotated());
+        assertEquals("v", 
ActionContext.getContext().getValueStack().findValue("annotated"));
+    }
+
+    public void testRequireAnnotations_annotatedNestedPath_isInjected() throws 
Exception {
+        configureRequireAnnotations(true, false);
+        AnnotatedAction action = new AnnotatedAction();
+        action.setNested(new NestedBean());
+        invokeWithCookies(action, new Cookie("nested.field", "v"));
+
+        assertEquals("v", action.getNested().getField());
+    }
+
+    public void testRequireAnnotations_unannotatedNestedPath_isSkipped() 
throws Exception {
+        configureRequireAnnotations(true, false);
+        AnnotatedAction action = new AnnotatedAction();
+        action.setUnannotatedNested(new NestedBean());
+        invokeWithCookies(action, new Cookie("unannotatedNested.field", "v"));
+
+        assertNull(action.getUnannotatedNested().getField());
+    }
+
+    public void testRequireAnnotations_transitionMode_exemptsDepthZero() 
throws Exception {
+        configureRequireAnnotations(true, true);
+        AnnotatedAction action = new AnnotatedAction();
+        invokeWithCookies(action, new Cookie("unannotated", "v"));
+
+        assertEquals("v", action.getUnannotated());
+    }
+
+    public void testDefaultConfig_unannotatedSetter_stillInjected() throws 
Exception {
+        configureRequireAnnotations(false, false);
+        AnnotatedAction action = new AnnotatedAction();
+        invokeWithCookies(action, new Cookie("unannotated", "v"));
+
+        assertEquals("v", action.getUnannotated());
+    }
+
+    public void testRequireAnnotations_modelDriven_exemptsModel() throws 
Exception {
+        configureRequireAnnotations(true, false);
+        ModelDrivenAction action = new ModelDrivenAction();
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setCookies(new Cookie("name", "v"));
+        ServletActionContext.setRequest(request);
+        // ModelDriven contract: the model is pushed on top of the action.
+        ActionContext.getContext().getValueStack().push(action);
+        ActionContext.getContext().getValueStack().push(action.getModel());
+
+        MockActionInvocation invocation = new MockActionInvocation();
+        invocation.setAction(action);
+        invocation.setInvocationContext(ActionContext.getContext());
+        invocation.setResultCode(Action.SUCCESS);
+
+        interceptor.intercept(invocation);
+
+        assertEquals("v", action.getModel().getName());
+    }
+
+    public void 
testSubclassOverridingDeprecatedHook_stillSeesAuthorizationGate() throws 
Exception {
+        configureRequireAnnotations(true, false);
+        AtomicInteger calls = new AtomicInteger();
+        @SuppressWarnings("deprecation")
+        CookieInterceptor subclass = new CookieInterceptor() {
+            @Override
+            protected void populateCookieValueIntoStack(String name, String 
value, Map<String, String> map, ValueStack stack) {
+                calls.incrementAndGet();
+                super.populateCookieValueIntoStack(name, value, map, stack);
+            }
+        };
+        container.inject(subclass);
+        subclass.setCookiesName("*");
+
+        AnnotatedAction action = new AnnotatedAction();
+        MockHttpServletRequest req = new MockHttpServletRequest();
+        req.setCookies(new Cookie("annotated", "ok"), new 
Cookie("unannotated", "blocked"));
+        ServletActionContext.setRequest(req);
+        ActionContext.getContext().getValueStack().push(action);
+
+        MockActionInvocation invocation = new MockActionInvocation();
+        invocation.setAction(action);
+        invocation.setInvocationContext(ActionContext.getContext());
+        invocation.setResultCode(Action.SUCCESS);
+        subclass.intercept(invocation);
+
+        assertEquals("ok", action.getAnnotated());
+        assertNull(action.getUnannotated());
+        assertEquals("4-arg hook should be invoked exactly once (only for the 
authorized cookie)", 1, calls.get());
+    }
+
+    private void configureRequireAnnotations(boolean require, boolean 
transitionMode) {
+        StrutsParameterAuthorizer authorizer = (StrutsParameterAuthorizer) 
container.getInstance(ParameterAuthorizer.class);
+        authorizer.setRequireAnnotations(Boolean.toString(require));
+        
authorizer.setRequireAnnotationsTransitionMode(Boolean.toString(transitionMode));
+    }
+
+    private void invokeWithCookies(Object action, Cookie... cookies) throws 
Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setCookies(cookies);
+        ServletActionContext.setRequest(request);
+        ActionContext.getContext().getValueStack().push(action);
+
+        MockActionInvocation invocation = new MockActionInvocation();
+        invocation.setAction(action);
+        invocation.setInvocationContext(ActionContext.getContext());
+        invocation.setResultCode(Action.SUCCESS);
+
+        interceptor.intercept(invocation);
+    }
+
+    public static class AnnotatedAction extends ActionSupport {
+        private String annotated;
+        private String unannotated;
+        private NestedBean nested;
+        private NestedBean unannotatedNested;
+
+        @StrutsParameter
+        public void setAnnotated(String v) { this.annotated = v; }
+        public String getAnnotated() { return annotated; }
+
+        public void setUnannotated(String v) { this.unannotated = v; }
+        public String getUnannotated() { return unannotated; }
+
+        @StrutsParameter(depth = 1)
+        public NestedBean getNested() { return nested; }
+        public void setNested(NestedBean nested) { this.nested = nested; }
+
+        public NestedBean getUnannotatedNested() { return unannotatedNested; }
+        public void setUnannotatedNested(NestedBean v) { 
this.unannotatedNested = v; }
+    }
+
+    public static class NestedBean {
+        private String field;
+        public String getField() { return field; }
+        public void setField(String f) { this.field = f; }
+    }
+
+    public static class ModelDrivenAction extends ActionSupport implements 
ModelDriven<Model> {
+        private final Model model = new Model();
+        @Override
+        public Model getModel() { return model; }
+    }
+
+    public static class Model {
+        private String name;
+        public String getName() { return name; }
+        public void setName(String n) { this.name = n; }
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/CookieInterceptorTest.java 
b/core/src/test/java/org/apache/struts2/interceptor/CookieInterceptorTest.java
index 8189f5ce8..8bcfe704c 100644
--- 
a/core/src/test/java/org/apache/struts2/interceptor/CookieInterceptorTest.java
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/CookieInterceptorTest.java
@@ -43,6 +43,15 @@ import static org.easymock.EasyMock.verify;
 
 public class CookieInterceptorTest extends StrutsInternalTestCase {
 
+    /**
+     * These tests construct {@link CookieInterceptor} via {@code new} rather 
than the DI container, so the
+     * {@code @StrutsParameter} authorization gate added in WW-5627 has no 
injected services. We supply explicit
+     * pass-through lambdas to mirror the default-config behavior these tests 
assume ({@code requireAnnotations=false}).
+     */
+    private static void disableAuthorizationGate(CookieInterceptor 
interceptor) {
+        interceptor.setParameterAuthorizer((name, target, action) -> true);
+        interceptor.setParameterAllowlister((name, target) -> {});
+    }
 
     public void testIntercepDefault() throws Exception {
         MockHttpServletRequest request = new MockHttpServletRequest();
@@ -68,6 +77,7 @@ public class CookieInterceptorTest extends 
StrutsInternalTestCase {
         CookieInterceptor interceptor = new CookieInterceptor();
         interceptor.setExcludedPatternsChecker(new 
DefaultExcludedPatternsChecker());
         interceptor.setAcceptedPatternsChecker(new 
DefaultAcceptedPatternsChecker());
+        disableAuthorizationGate(interceptor);
 
         interceptor.intercept(invocation);
 
@@ -105,6 +115,7 @@ public class CookieInterceptorTest extends 
StrutsInternalTestCase {
         CookieInterceptor interceptor = new CookieInterceptor();
         interceptor.setExcludedPatternsChecker(new 
DefaultExcludedPatternsChecker());
         interceptor.setAcceptedPatternsChecker(new 
DefaultAcceptedPatternsChecker());
+        disableAuthorizationGate(interceptor);
         interceptor.setCookiesName("*");
         interceptor.setCookiesValue("*");
         interceptor.intercept(invocation);
@@ -147,6 +158,7 @@ public class CookieInterceptorTest extends 
StrutsInternalTestCase {
         CookieInterceptor interceptor = new CookieInterceptor();
         interceptor.setExcludedPatternsChecker(new 
DefaultExcludedPatternsChecker());
         interceptor.setAcceptedPatternsChecker(new 
DefaultAcceptedPatternsChecker());
+        disableAuthorizationGate(interceptor);
         interceptor.setCookiesName("cookie1, cookie2, cookie3");
         interceptor.setCookiesValue("cookie1value, cookie2value, 
cookie3value");
         interceptor.intercept(invocation);
@@ -188,6 +200,7 @@ public class CookieInterceptorTest extends 
StrutsInternalTestCase {
         CookieInterceptor interceptor = new CookieInterceptor();
         interceptor.setExcludedPatternsChecker(new 
DefaultExcludedPatternsChecker());
         interceptor.setAcceptedPatternsChecker(new 
DefaultAcceptedPatternsChecker());
+        disableAuthorizationGate(interceptor);
         interceptor.setCookiesName("cookie1, cookie3");
         interceptor.setCookiesValue("cookie1value, cookie2value, 
cookie3value");
         interceptor.intercept(invocation);
@@ -230,6 +243,7 @@ public class CookieInterceptorTest extends 
StrutsInternalTestCase {
         CookieInterceptor interceptor = new CookieInterceptor();
         interceptor.setExcludedPatternsChecker(new 
DefaultExcludedPatternsChecker());
         interceptor.setAcceptedPatternsChecker(new 
DefaultAcceptedPatternsChecker());
+        disableAuthorizationGate(interceptor);
         interceptor.setCookiesName("cookie1, cookie3");
         interceptor.setCookiesValue("*");
         interceptor.intercept(invocation);
@@ -271,6 +285,7 @@ public class CookieInterceptorTest extends 
StrutsInternalTestCase {
         CookieInterceptor interceptor = new CookieInterceptor();
         interceptor.setExcludedPatternsChecker(new 
DefaultExcludedPatternsChecker());
         interceptor.setAcceptedPatternsChecker(new 
DefaultAcceptedPatternsChecker());
+        disableAuthorizationGate(interceptor);
         interceptor.setCookiesName("cookie1, cookie3");
         interceptor.setCookiesValue("");
         interceptor.intercept(invocation);
@@ -313,6 +328,7 @@ public class CookieInterceptorTest extends 
StrutsInternalTestCase {
         CookieInterceptor interceptor = new CookieInterceptor();
         interceptor.setExcludedPatternsChecker(new 
DefaultExcludedPatternsChecker());
         interceptor.setAcceptedPatternsChecker(new 
DefaultAcceptedPatternsChecker());
+        disableAuthorizationGate(interceptor);
         interceptor.setCookiesName("cookie1, cookie3");
         interceptor.setCookiesValue("cookie1value");
         interceptor.intercept(invocation);
@@ -395,6 +411,7 @@ public class CookieInterceptorTest extends 
StrutsInternalTestCase {
         
excludedPatternsChecker.setAdditionalExcludePatterns(".*(^|\\.|\\[|'|\")class(\\.|\\[|'|\").*");
         interceptor.setExcludedPatternsChecker(excludedPatternsChecker);
         interceptor.setAcceptedPatternsChecker(new 
DefaultAcceptedPatternsChecker());
+        disableAuthorizationGate(interceptor);
         interceptor.setCookiesName("*");
 
         MockActionInvocation invocation = new MockActionInvocation();
@@ -441,6 +458,7 @@ public class CookieInterceptorTest extends 
StrutsInternalTestCase {
         };
         interceptor.setExcludedPatternsChecker(new 
DefaultExcludedPatternsChecker());
         interceptor.setAcceptedPatternsChecker(new 
DefaultAcceptedPatternsChecker());
+        disableAuthorizationGate(interceptor);
         interceptor.setCookiesName("*");
 
         MockActionInvocation invocation = new MockActionInvocation();
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/parameter/OgnlParameterAllowlisterTest.java
 
b/core/src/test/java/org/apache/struts2/interceptor/parameter/OgnlParameterAllowlisterTest.java
new file mode 100644
index 000000000..7066a9bee
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/parameter/OgnlParameterAllowlisterTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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
+ *
+ *  http://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.
+ */
+package org.apache.struts2.interceptor.parameter;
+
+import org.apache.struts2.ognl.DefaultOgnlBeanInfoCacheFactory;
+import org.apache.struts2.ognl.DefaultOgnlExpressionCacheFactory;
+import org.apache.struts2.ognl.OgnlUtil;
+import org.apache.struts2.ognl.StrutsOgnlGuard;
+import org.apache.struts2.ognl.StrutsProxyCacheFactory;
+import org.apache.struts2.ognl.ThreadAllowlist;
+import org.apache.struts2.util.StrutsProxyService;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.apache.struts2.ognl.OgnlCacheFactory.CacheType.LRU;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class OgnlParameterAllowlisterTest {
+
+    private OgnlParameterAllowlister allowlister;
+    private RecordingThreadAllowlist threadAllowlist;
+
+    @Before
+    public void setUp() {
+        threadAllowlist = new RecordingThreadAllowlist();
+        allowlister = new OgnlParameterAllowlister();
+        var ognlUtil = new OgnlUtil(
+                new DefaultOgnlExpressionCacheFactory<>(String.valueOf(1000), 
LRU.toString()),
+                new DefaultOgnlBeanInfoCacheFactory<>(String.valueOf(1000), 
LRU.toString()),
+                new StrutsOgnlGuard());
+        allowlister.setOgnlUtil(ognlUtil);
+        allowlister.setProxyService(new StrutsProxyService(new 
StrutsProxyCacheFactory<>("1000", "basic")));
+        allowlister.setThreadAllowlist(threadAllowlist);
+    }
+
+    @After
+    public void tearDown() {
+        threadAllowlist.clear();
+    }
+
+    @Test
+    public void depthZero_isNoOp() {
+        var target = new TargetWithAnnotatedNestedBean();
+        allowlister.primeAllowlistForPath("simple", target);
+        assertThat(threadAllowlist.classes).isEmpty();
+    }
+
+    @Test
+    public void nestedProperty_allowlistsPropertyType() {
+        var target = new TargetWithAnnotatedNestedBean();
+        allowlister.primeAllowlistForPath("nested.field", target);
+        assertThat(threadAllowlist.classes).contains(NestedBean.class);
+    }
+
+    @Test
+    public void parameterizedReturn_allowlistsTypeArguments() {
+        var target = new TargetWithAnnotatedNestedBean();
+        allowlister.primeAllowlistForPath("things[0].field", target);
+        assertThat(threadAllowlist.classes).contains(List.class, 
NestedBean.class);
+    }
+
+    @Test
+    public void publicField_isAllowlistedWhenNoGetter() {
+        var target = new TargetWithAnnotatedPublicField();
+        allowlister.primeAllowlistForPath("publicNested.field", target);
+        assertThat(threadAllowlist.classes).contains(NestedBean.class);
+    }
+
+    @Test
+    public void unmatchedRoot_isNoOp() {
+        var target = new TargetWithAnnotatedNestedBean();
+        allowlister.primeAllowlistForPath("unknownRoot.field", target);
+        assertThat(threadAllowlist.classes).isEmpty();
+    }
+
+    @Test
+    public void unannotatedNested_isNoOp() {
+        var target = new TargetWithUnannotatedNested();
+        allowlister.primeAllowlistForPath("unannotated.field", target);
+        assertThat(threadAllowlist.classes).isEmpty();
+    }
+
+    public static class TargetWithAnnotatedNestedBean {
+        private NestedBean nested;
+        private List<NestedBean> things;
+
+        @StrutsParameter(depth = 1)
+        public NestedBean getNested() { return nested; }
+        public void setNested(NestedBean nested) { this.nested = nested; }
+
+        @StrutsParameter(depth = 2)
+        public List<NestedBean> getThings() { return things; }
+        public void setThings(List<NestedBean> things) { this.things = things; 
}
+    }
+
+    public static class TargetWithAnnotatedPublicField {
+        @StrutsParameter(depth = 1)
+        public NestedBean publicNested;
+    }
+
+    public static class TargetWithUnannotatedNested {
+        private NestedBean unannotated;
+        public NestedBean getUnannotated() { return unannotated; }
+        public void setUnannotated(NestedBean v) { this.unannotated = v; }
+    }
+
+    public static class NestedBean {
+        private String field;
+        public String getField() { return field; }
+        public void setField(String f) { this.field = f; }
+    }
+
+    private static final class RecordingThreadAllowlist extends 
ThreadAllowlist {
+        final Set<Class<?>> classes = new HashSet<>();
+
+        @Override
+        public void allowClass(Class<?> clazz) {
+            classes.add(clazz);
+            super.allowClass(clazz);
+        }
+
+        @Override
+        public void allowClassHierarchy(Class<?> clazz) {
+            classes.add(clazz);
+            super.allowClassHierarchy(clazz);
+        }
+
+        void clear() {
+            classes.clear();
+            clearAllowlist();
+        }
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/parameter/StrutsParameterAnnotationTest.java
 
b/core/src/test/java/org/apache/struts2/interceptor/parameter/StrutsParameterAnnotationTest.java
index 803e0dad5..8ec445253 100644
--- 
a/core/src/test/java/org/apache/struts2/interceptor/parameter/StrutsParameterAnnotationTest.java
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/parameter/StrutsParameterAnnotationTest.java
@@ -84,6 +84,12 @@ public class StrutsParameterAnnotationTest {
         this.parameterAuthorizer = parameterAuthorizer;
         parametersInterceptor.setParameterAuthorizer(parameterAuthorizer);
 
+        var parameterAllowlister = new OgnlParameterAllowlister();
+        parameterAllowlister.setOgnlUtil(ognlUtil);
+        parameterAllowlister.setProxyService(proxyService);
+        parameterAllowlister.setThreadAllowlist(threadAllowlist);
+        parametersInterceptor.setParameterAllowlister(parameterAllowlister);
+
         NotExcludedAcceptedPatternsChecker checker = 
mock(NotExcludedAcceptedPatternsChecker.class);
         when(checker.isAccepted(anyString())).thenReturn(IsAccepted.yes(""));
         
when(checker.isExcluded(anyString())).thenReturn(IsExcluded.no(Set.of()));

Reply via email to