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 39bef89be WW-5833 fix(rest): authorize nested container members at the 
depth ParametersInterceptor counts (#2091)
39bef89be is described below

commit 39bef89be42ba680a8f6f14c53e345c3120732a9
Author: g0w6y <[email protected]>
AuthorDate: Sat Nov 7 13:39:00 2026 +0530

    WW-5833 fix(rest): authorize nested container members at the depth 
ParametersInterceptor counts (#2091)
    
    AuthorizingValueDeserializer appended one [0] to the path of a container 
property, so the members
    of List<List<Bean>>, Map<String, List<Bean>> and Bean[][] were authorized 
one depth level short
    of the same name on the form path. Add one index per container level, and 
recurse into nested
    containers in the two-phase copy of ContentTypeInterceptor.
---
 .../struts2/rest/ContentTypeInterceptor.java       | 22 ++++++++++++
 .../jackson/AuthorizingValueDeserializer.java      | 13 ++++---
 .../ContentTypeInterceptorIntegrationTest.java     | 40 +++++++++++++++++++++-
 .../org/apache/struts2/rest/SecureRestAction.java  |  7 ++++
 .../jackson/ParameterAuthorizingModuleTest.java    | 27 +++++++++++++++
 5 files changed, 103 insertions(+), 6 deletions(-)

diff --git 
a/plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java
 
b/plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java
index d98b22544..832f77e14 100644
--- 
a/plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java
+++ 
b/plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java
@@ -328,6 +328,8 @@ public class ContentTypeInterceptor extends 
AbstractInterceptor {
                     LOG.warn("REST collection element [{}] skipped — no no-arg 
constructor for [{}]",
                             elementPath, element.getClass().getName());
                 }
+            } else if (isContainer(element)) {
+                result.add(deepCopyAuthorizedContainer(element, collectionPath 
+ "[0]", authTarget, action));
             } else {
                 result.add(element);
             }
@@ -366,6 +368,8 @@ public class ContentTypeInterceptor extends 
AbstractInterceptor {
                     LOG.warn("REST map value [{}] skipped — no no-arg 
constructor for [{}]",
                             valuePath, value.getClass().getName());
                 }
+            } else if (isContainer(value)) {
+                result.put(entry.getKey(), deepCopyAuthorizedContainer(value, 
mapPath + "[0]", authTarget, action));
             } else {
                 result.put(entry.getKey(), value);
             }
@@ -398,6 +402,8 @@ public class ContentTypeInterceptor extends 
AbstractInterceptor {
                     LOG.warn("REST array element [{}] skipped — no no-arg 
constructor for [{}]",
                             elementPath, element.getClass().getName());
                 }
+            } else if (isContainer(element)) {
+                Array.set(result, i, deepCopyAuthorizedContainer(element, 
arrayPath + "[0]", authTarget, action));
             } else {
                 Array.set(result, i, element);
             }
@@ -405,6 +411,22 @@ public class ContentTypeInterceptor extends 
AbstractInterceptor {
         return result;
     }
 
+    private static boolean isContainer(Object value) {
+        return value instanceof Collection || value instanceof Map || (value 
!= null && value.getClass().isArray());
+    }
+
+    /** A container nested in a container sits one index level deeper, as in 
{@code List<List<Bean>>}. */
+    private Object deepCopyAuthorizedContainer(Object value, String path, 
Object authTarget, Object action)
+            throws Exception {
+        if (value instanceof Collection) {
+            return deepCopyAuthorizedCollection((Collection<?>) value, path, 
authTarget, action);
+        }
+        if (value instanceof Map) {
+            return deepCopyAuthorizedMap((Map<?, ?>) value, path, authTarget, 
action);
+        }
+        return deepCopyAuthorizedArray(value, path, authTarget, action);
+    }
+
     /**
      * Determines whether a class represents a nested bean that should be 
recursively authorized,
      * as opposed to simple/leaf types (primitives, strings, collections, 
maps, arrays, enums) that
diff --git 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java
 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java
index 2676c0e2d..b08f0b924 100644
--- 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java
+++ 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java
@@ -168,14 +168,17 @@ final class AuthorizingValueDeserializer extends 
DelegatingDeserializer {
 
     /**
      * For Collection / Map / Array properties, the path to push for nested 
element members is
-     * {@code path + "[0]"} -- matching {@code ParametersInterceptor} 
bracket-depth semantics. Scalar /
+     * {@code path + "[0]"} per container level -- matching {@code 
ParametersInterceptor} bracket-depth
+     * semantics, so {@code List<List<Bean>>} members sit under {@code 
path[0][0]}. Scalar /
      * bean-valued properties push the path unchanged.
      */
     private String prefixForNested(String pathOfThisProperty) {
-        if (propertyType != null
-                && (propertyType.isCollectionLikeType() || 
propertyType.isMapLikeType() || propertyType.isArrayType())) {
-            return pathOfThisProperty + "[0]";
+        StringBuilder prefix = new StringBuilder(pathOfThisProperty);
+        for (JavaType type = propertyType; type != null
+                && (type.isCollectionLikeType() || type.isMapLikeType() || 
type.isArrayType());
+             type = type.getContentType()) {
+            prefix.append("[0]");
         }
-        return pathOfThisProperty;
+        return prefix.toString();
     }
 }
diff --git 
a/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
 
b/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
index 0ac5ce458..48cb779de 100644
--- 
a/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
+++ 
b/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
@@ -20,6 +20,7 @@ package org.apache.struts2.rest;
 
 import com.fasterxml.jackson.annotation.JsonAnySetter;
 import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.mockobjects.dynamic.AnyConstraintMatcher;
 import com.mockobjects.dynamic.Mock;
 import junit.framework.TestCase;
@@ -47,6 +48,7 @@ import org.apache.struts2.util.StrutsProxyService;
 import org.apache.struts2.util.ValueStack;
 import org.springframework.mock.web.MockHttpServletRequest;
 
+import java.io.IOException;
 import java.io.Reader;
 import java.io.Writer;
 import java.util.ArrayList;
@@ -65,6 +67,7 @@ import static 
org.apache.struts2.ognl.OgnlCacheFactory.CacheType.LRU;
 public class ContentTypeInterceptorIntegrationTest extends TestCase {
 
     private ContentTypeInterceptor interceptor;
+    private StrutsParameterAuthorizer authorizer;
     private SecureRestAction action;
     private Object configuredAction;
     private Mock mockActionInvocation;
@@ -95,7 +98,7 @@ public class ContentTypeInterceptorIntegrationTest extends 
TestCase {
                 new StrutsOgnlGuard());
         var proxyService = new StrutsProxyService(new 
StrutsProxyCacheFactory<>("1000", "basic"));
 
-        StrutsParameterAuthorizer authorizer = new StrutsParameterAuthorizer();
+        authorizer = new StrutsParameterAuthorizer();
         authorizer.setOgnlUtil(ognlUtil);
         authorizer.setProxyService(proxyService);
         authorizer.setRequireAnnotations(Boolean.TRUE.toString());
@@ -178,6 +181,24 @@ public class ContentTypeInterceptorIntegrationTest extends 
TestCase {
                 action.getShallowAddress().getZip());
     }
 
+    public void 
testNestedContainerMembersAreRejectedAtTheDepthTheFormPathRejects() throws 
Exception {
+        assertTrue(authorizer.isAuthorized("addressRows[0][0]", action, 
action));
+        assertFalse(authorizer.isAuthorized("addressRows[0][0].city", action, 
action));
+
+        runWithBody("{\"addressRows\":[[{\"city\":\"Warsaw\"}]]}");
+
+        assertNotNull(action.getAddressRows().get(0).get(0));
+        assertNull(action.getAddressRows().get(0).get(0).getCity());
+    }
+
+    public void testTwoPhaseCopyRejectsNestedContainerMembersAtTheSameDepth() 
throws Exception {
+        setupInterceptorWithHandler(action, new PlainJacksonHandler());
+
+        runWithBody("{\"addressRows\":[[{\"city\":\"Warsaw\"}]]}");
+
+        assertNull(action.getAddressRows().get(0).get(0).getCity());
+    }
+
     // --- WW-5789: a handler that binds nothing must leave the model as the 
params interceptor left it ---
 
     public void testFormUrlEncodedBodyKeepsValuesBoundOnTheModel() throws 
Exception {
@@ -373,6 +394,23 @@ public class ContentTypeInterceptorIntegrationTest extends 
TestCase {
                 + " unannotated setAdmin", merged.admin());
     }
 
+    /** A handler without the authorization-aware marker, so the interceptor 
takes the two-phase copy. */
+    public static class PlainJacksonHandler implements ContentTypeHandler {
+        @Override
+        public void toObject(ActionInvocation invocation, Reader in, Object 
target) throws IOException {
+            new ObjectMapper().readerForUpdating(target).readValue(in);
+        }
+
+        @Override
+        public String fromObject(ActionInvocation invocation, Object obj, 
String resultCode, Writer stream) { return null; }
+
+        @Override
+        public String getContentType() { return "application/json"; }
+
+        @Override
+        public String getExtension() { return "json"; }
+    }
+
     /** Stands in for a third-party handler that leaves the module's thread 
state behind. */
     public static class StatePlantingHandler implements 
AuthorizationAwareContentTypeHandler {
         @Override
diff --git 
a/plugins/rest/src/test/java/org/apache/struts2/rest/SecureRestAction.java 
b/plugins/rest/src/test/java/org/apache/struts2/rest/SecureRestAction.java
index 8ca032f50..b7d24edec 100644
--- a/plugins/rest/src/test/java/org/apache/struts2/rest/SecureRestAction.java
+++ b/plugins/rest/src/test/java/org/apache/struts2/rest/SecureRestAction.java
@@ -36,6 +36,7 @@ public class SecureRestAction extends ActionSupport {
     private Address shallowAddress;
     private List<String> tags;
     private List<Address> addresses;
+    private List<List<Address>> addressRows;
     private Map<String, String> attributes;
     private String[] aliases;
 
@@ -76,6 +77,12 @@ public class SecureRestAction extends ActionSupport {
     @StrutsParameter
     public void setAddresses(List<Address> addresses) { this.addresses = 
addresses; }
 
+    @StrutsParameter(depth = 2)
+    public List<List<Address>> getAddressRows() { return addressRows; }
+
+    @StrutsParameter
+    public void setAddressRows(List<List<Address>> addressRows) { 
this.addressRows = addressRows; }
+
     @StrutsParameter(depth = 1)
     public Map<String, String> getAttributes() { return attributes; }
 
diff --git 
a/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java
 
b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java
index 213b9edee..a6d5840ac 100644
--- 
a/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java
+++ 
b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java
@@ -151,6 +151,30 @@ public class ParameterAuthorizingModuleTest extends 
TestCase {
         assertNull(result.addresses.get(0).zip);
     }
 
+    public void testNestedListUsesOneIndexPerContainerLevel() throws Exception 
{
+        bind((path, t, a) -> "addressRows".equals(path) || 
"addressRows[0][0].city".equals(path), new Person());
+        Person result = mapper.readValue(
+                
"{\"addressRows\":[[{\"city\":\"Warsaw\",\"zip\":\"00-001\"}]]}", Person.class);
+        assertEquals("Warsaw", result.addressRows.get(0).get(0).city);
+        assertNull(result.addressRows.get(0).get(0).zip);
+    }
+
+    public void testMapOfListUsesOneIndexPerContainerLevel() throws Exception {
+        bind((path, t, a) -> "addressesByKey".equals(path) || 
"addressesByKey[0][0].city".equals(path), new Person());
+        Person result = mapper.readValue(
+                
"{\"addressesByKey\":{\"home\":[{\"city\":\"Warsaw\",\"zip\":\"00-001\"}]}}", 
Person.class);
+        assertEquals("Warsaw", result.addressesByKey.get("home").get(0).city);
+        assertNull(result.addressesByKey.get("home").get(0).zip);
+    }
+
+    public void testArrayOfArrayUsesOneIndexPerContainerLevel() throws 
Exception {
+        bind((path, t, a) -> "addressGrid".equals(path) || 
"addressGrid[0][0].city".equals(path), new Person());
+        Person result = mapper.readValue(
+                
"{\"addressGrid\":[[{\"city\":\"Warsaw\",\"zip\":\"00-001\"}]]}", Person.class);
+        assertEquals("Warsaw", result.addressGrid[0][0].city);
+        assertNull(result.addressGrid[0][0].zip);
+    }
+
     public void testArrayUsesIndexedPath() throws Exception {
         bind((path, t, a) -> "addressArray".equals(path) || 
"addressArray[0].city".equals(path), new Person());
         Person result = mapper.readValue(
@@ -1168,6 +1192,9 @@ public class ParameterAuthorizingModuleTest extends 
TestCase {
         public java.util.List<Address> addresses;
         public Address[] addressArray;
         public java.util.Map<String, Address> addressMap;
+        public java.util.List<java.util.List<Address>> addressRows;
+        public java.util.Map<String, java.util.List<Address>> addressesByKey;
+        public Address[][] addressGrid;
         public RecordAddress recordAddress;
     }
 

Reply via email to