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 2f712f110 WW-5710 fix(core): prime the OGNL allowlist against the 
action as well as the model (#1932)
2f712f110 is described below

commit 2f712f110a23219726862b0c7bca1e0bc3a046e5
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Sep 13 19:37:14 2026 +0200

    WW-5710 fix(core): prime the OGNL allowlist against the action as well as 
the model (#1932)
    
    Both OGNL channels resolved the binding target once and used it for the
    authorization check and the allowlist priming alike. For a ModelDriven
    action that target is the model, but since WW-5698 the authorizer may
    grant a parameter on a member declared on the action itself. The
    allowlister was still handed the model only, found no annotation there
    and primed nothing, so with struts.allowlist.enable=true - the default -
    a correctly annotated nested property on the action's own class was
    authorized and then refused by SecurityMemberAccess, with nothing in the
    logs pointing at the allowlist.
    
    ParameterAllowlister gains a default three-argument form that primes the
    resolved target and, when it differs, the action too; ParametersInterceptor
    and CookieInterceptor use it. Each priming is a no-op unless that object
    annotates the root property, so the second priming cannot allowlist
    anything the developer did not declare.
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../struts2/interceptor/CookieInterceptor.java     |  2 +-
 .../parameter/OgnlParameterAllowlister.java        |  9 ++--
 .../parameter/ParameterAllowlister.java            | 18 +++++++
 .../parameter/ParametersInterceptor.java           |  2 +-
 .../struts2/interceptor/CookieInterceptorTest.java | 57 ++++++++++++++++++++++
 .../parameter/ParametersInterceptorTest.java       | 53 ++++++++++++++++++++
 .../parameter/StrutsParameterAnnotationTest.java   | 31 ++++++++++++
 7 files changed, 165 insertions(+), 7 deletions(-)

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 4d9685543..551887d4b 100644
--- a/core/src/main/java/org/apache/struts2/interceptor/CookieInterceptor.java
+++ b/core/src/main/java/org/apache/struts2/interceptor/CookieInterceptor.java
@@ -358,7 +358,7 @@ public class CookieInterceptor extends AbstractInterceptor {
             LOG.debug("Cookie [{}] rejected by @StrutsParameter authorization 
on target [{}]", cookieName, target.getClass().getSimpleName());
             return;
         }
-        parameterAllowlister.primeAllowlistForPath(cookieName, target);
+        parameterAllowlister.primeAllowlistForPath(cookieName, target, action);
         populateCookieValueIntoStack(cookieName, cookieValue, cookiesMap, 
stack);
     }
 
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
index 0a24d8577..71e8f20fc 100644
--- 
a/core/src/main/java/org/apache/struts2/interceptor/parameter/OgnlParameterAllowlister.java
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/parameter/OgnlParameterAllowlister.java
@@ -52,7 +52,7 @@ import static 
org.apache.struts2.security.DefaultAcceptedPatternsChecker.NESTING
  *   <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>
+ *       {@code LOG.debug} names the object that primed nothing, so a path 
OGNL then refuses can be traced back.</li>
  * </ul>
  *
  * @since 7.2.0
@@ -100,10 +100,9 @@ public class OgnlParameterAllowlister implements 
ParameterAllowlister {
         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",
+        // No @StrutsParameter on the root property of this object - e.g. a 
ModelDriven model with no per-property
+        // annotations. Whether OGNL can still walk the path depends on the 
other objects primed for it.
+        LOG.debug("Parameter [{}] has no @StrutsParameter on root property 
[{}] of [{}]; nothing allowlisted from it",
                 parameterName, normalisedRootProperty, 
ultimateClass(target).getSimpleName());
     }
 
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
index 846498c71..b70f7bbe7 100644
--- 
a/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAllowlister.java
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAllowlister.java
@@ -41,4 +41,22 @@ public interface ParameterAllowlister {
      * @param target        the object receiving the parameter value (the 
action, or the model for ModelDriven actions)
      */
     void primeAllowlistForPath(String parameterName, Object target);
+
+    /**
+     * Primes for a parameter that {@link ParameterAuthorizer#isAuthorized} 
may have granted on either object: a
+     * {@link org.apache.struts2.ModelDriven} action's own annotated members 
are authorized on the action while the
+     * resolved target is its model, so both are primed. Each priming is a 
no-op unless that object annotates the
+     * root property, so priming the second object cannot allowlist anything 
the developer did not declare.
+     *
+     * @param parameterName the parameter name (e.g. {@code "user.role"}, 
{@code "items[0].name"})
+     * @param target        the object receiving the parameter value, as 
resolved by {@link ParameterAuthorizer#resolveTarget}
+     * @param action        the action instance
+     * @since 7.4.0
+     */
+    default void primeAllowlistForPath(String parameterName, Object target, 
Object action) {
+        primeAllowlistForPath(parameterName, target);
+        if (target != action) {
+            primeAllowlistForPath(parameterName, action);
+        }
+    }
 }
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 365868a2b..22d575eab 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
@@ -378,7 +378,7 @@ public class ParametersInterceptor extends 
MethodFilterInterceptor {
             return false;
         }
 
-        parameterAllowlister.primeAllowlistForPath(name, target);
+        parameterAllowlister.primeAllowlistForPath(name, target, action);
         return true;
     }
 
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 8bcfe704c..0314be46a 100644
--- 
a/core/src/test/java/org/apache/struts2/interceptor/CookieInterceptorTest.java
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/CookieInterceptorTest.java
@@ -29,7 +29,11 @@ import jakarta.servlet.http.Cookie;
 import org.apache.struts2.ServletActionContext;
 import org.apache.struts2.StrutsInternalTestCase;
 import org.apache.struts2.action.CookiesAware;
+import org.apache.struts2.ModelDriven;
+import org.apache.struts2.interceptor.parameter.ParameterAllowlister;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
 import org.apache.struts2.interceptor.parameter.StrutsParameter;
+import org.apache.struts2.ognl.ThreadAllowlist;
 import org.springframework.mock.web.MockHttpServletRequest;
 
 import java.util.Collections;
@@ -471,6 +475,59 @@ public class CookieInterceptorTest extends 
StrutsInternalTestCase {
         assertFalse(excludedName.get(reqCookieName));
     }
 
+    /**
+     * WW-5710: a nested cookie path annotated on the ModelDriven action 
itself is authorized on the action, so the
+     * allowlist has to be primed against the action as well, not only against 
the model.
+     */
+    public void testNestedCookieOnModelDrivenActionMemberPrimesAllowlist() 
throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setCookies(new Cookie("address.city", "London"));
+        ServletActionContext.setRequest(request);
+
+        ModelDrivenActionWithNestedMember action = new 
ModelDrivenActionWithNestedMember();
+        ActionContext.getContext().getValueStack().push(action);
+        ActionContext.getContext().getValueStack().push(action.getModel());
+
+        ActionInvocation invocation = (ActionInvocation) 
createMock(ActionInvocation.class);
+        expect(invocation.getAction()).andReturn(action);
+        expect(invocation.invoke()).andReturn(Action.SUCCESS);
+        replay(invocation);
+
+        CookieInterceptor interceptor = new CookieInterceptor();
+        interceptor.setCookiesName("*");
+        interceptor.setExcludedPatternsChecker(new 
DefaultExcludedPatternsChecker());
+        interceptor.setAcceptedPatternsChecker(new 
DefaultAcceptedPatternsChecker());
+        
interceptor.setParameterAuthorizer(container.getInstance(ParameterAuthorizer.class));
+        
interceptor.setParameterAllowlister(container.getInstance(ParameterAllowlister.class));
+        ThreadAllowlist threadAllowlist = 
container.getInstance(ThreadAllowlist.class);
+
+        try {
+            interceptor.intercept(invocation);
+            assertTrue(threadAllowlist.getAllowlist().contains(Address.class));
+            assertEquals("London", action.getAddress().getCity());
+        } finally {
+            threadAllowlist.clearAllowlist();
+        }
+        verify(invocation);
+    }
+
+    public static class ModelDrivenActionWithNestedMember extends 
ActionSupport implements ModelDriven<Object> {
+        private final Object model = new Object();
+        private final Address address = new Address();
+
+        @Override
+        public Object getModel() { return model; }
+
+        @StrutsParameter(depth = 1)
+        public Address getAddress() { return address; }
+    }
+
+    public static class Address {
+        private String city;
+        public String getCity() { return city; }
+        public void setCity(String city) { this.city = city; }
+    }
+
     public static class MockActionWithCookieAware extends ActionSupport 
implements CookiesAware {
 
         private static final long serialVersionUID = -6202290616812813386L;
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/parameter/ParametersInterceptorTest.java
 
b/core/src/test/java/org/apache/struts2/interceptor/parameter/ParametersInterceptorTest.java
index c5c7fffc0..de17ba7cb 100644
--- 
a/core/src/test/java/org/apache/struts2/interceptor/parameter/ParametersInterceptorTest.java
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/parameter/ParametersInterceptorTest.java
@@ -38,6 +38,7 @@ import org.apache.struts2.interceptor.ValidationAware;
 import org.apache.struts2.mock.MockActionInvocation;
 import org.apache.struts2.ognl.OgnlValueStack;
 import org.apache.struts2.ognl.OgnlValueStackFactory;
+import org.apache.struts2.ognl.ThreadAllowlist;
 import org.apache.struts2.ognl.SecurityMemberAccess;
 import org.apache.struts2.ognl.accessor.CompoundRootAccessor;
 import org.apache.struts2.ognl.accessor.RootAccessor;
@@ -283,6 +284,41 @@ public class ParametersInterceptorTest extends 
XWorkTestCase {
         assertNull(action.getSecret());
     }
 
+    /**
+     * WW-5710: a nested property annotated on the ModelDriven action itself 
is authorized on the action, but the
+     * allowlist used to be primed against the model only, so with the 
allowlist enabled (the default) OGNL refused
+     * the path the authorizer had just granted. The model property alongside 
proves the parameters were applied.
+     */
+    public void testModelDrivenAnnotatedNestedPropertyOnActionIsAllowlisted() 
throws Exception {
+        loadButSet(Map.of(
+                StrutsConstants.STRUTS_PARAMETERS_REQUIRE_ANNOTATIONS, "true",
+                StrutsConstants.STRUTS_ALLOWLIST_ENABLE, "true"));
+        ParametersInterceptor pi = createParametersInterceptor();
+
+        NestedModelDrivenAction action = new NestedModelDrivenAction();
+        ValueStack stack = 
container.getInstance(ValueStackFactory.class).createValueStack();
+        stack.push(action);
+        stack.push(action.getModel());
+        
ActionContext.of().withContainer(container).withValueStack(stack).bind();
+        // What configuration loading does for the action class and 
ModelDrivenInterceptor for the model;
+        // Address is allowlisted by nothing but the priming under test
+        ThreadAllowlist threadAllowlist = 
container.getInstance(ThreadAllowlist.class);
+        threadAllowlist.allowClassHierarchy(NestedModelDrivenAction.class);
+        threadAllowlist.allowClassHierarchy(TestBean.class);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("address.city", "bound through the action's annotated 
getter");
+        params.put("name", "bound on the model");
+        pi.applyParameters(action, stack, 
HttpParameters.create(params).build());
+
+        try {
+            assertEquals("bound on the model", action.getModel().getName());
+            assertEquals("bound through the action's annotated getter", 
action.getAddress().getCity());
+        } finally {
+            threadAllowlist.clearAllowlist();
+        }
+    }
+
     public void testParametersDoesNotAffectSession() throws Exception {
         Map<String, Object> params = new HashMap<>();
         params.put("blah", "This is blah");
@@ -1041,6 +1077,23 @@ public class ParametersInterceptorTest extends 
XWorkTestCase {
         public String getAllowed() { return allowed; }
     }
 
+    public static class NestedModelDrivenAction implements 
ModelDriven<TestBean> {
+        private final TestBean model = new TestBean();
+        private final Address address = new Address();
+
+        @Override
+        public TestBean getModel() { return model; }
+
+        @StrutsParameter(depth = 1)
+        public Address getAddress() { return address; }
+    }
+
+    public static class Address {
+        private String city;
+        public String getCity() { return city; }
+        public void setCity(String city) { this.city = city; }
+    }
+
     private class NoParametersAction implements Action, NoParameters {
 
         public String execute() throws Exception {
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 8ec445253..d4bc9f43d 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
@@ -424,6 +424,23 @@ public class StrutsParameterAnnotationTest {
         testParameter(proxiedAction, "name.nested", true);
     }
 
+    /**
+     * WW-5710: a nested property annotated on the ModelDriven action itself 
is authorized on the action, so the
+     * allowlist has to be primed against the action as well, not only against 
the model.
+     */
+    @Test
+    public void 
modelDrivenAction_annotatedNestedPropertyOnAction_allowlisted() {
+        var action = new ModelActionWithNestedProperty();
+
+        // Emulate ModelDrivenInterceptor running previously
+        var valueStack = new StubValueStack();
+        valueStack.push(action.getModel());
+        ActionContext.of().withValueStack(valueStack).bind();
+
+        testParameter(action, "publicPojo.key", true);
+        
assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
+    }
+
     public static class FieldAction {
         @StrutsParameter
         private String privateStr;
@@ -516,6 +533,20 @@ public class StrutsParameterAnnotationTest {
         }
     }
 
+    public static class ModelActionWithNestedProperty implements 
ModelDriven<Pojo> {
+        private final Pojo model = new Pojo();
+
+        @Override
+        public Pojo getModel() {
+            return model;
+        }
+
+        @StrutsParameter(depth = 1)
+        public Pojo getPublicPojo() {
+            return null;
+        }
+    }
+
     public static class Pojo {
     }
 }

Reply via email to