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 6d2713292 WW-4858 Evaluate JSON name allowlist at leaf keys only 
(#1784)
6d2713292 is described below

commit 6d271329262bd82db732ee3b4facd053b8407dc0
Author: Lukasz Lenart <[email protected]>
AuthorDate: Tue Jul 21 10:59:38 2026 +0200

    WW-4858 Evaluate JSON name allowlist at leaf keys only (#1784)
    
    * WW-4858 fix(json): evaluate name allowlist at leaf keys only
    
    The JSON population filter walked the object tree and applied every name
    check at every node before recursing. Accepted name patterns and the
    ParameterNameAware callback target the full dotted binding path, so gating
    an intermediate node (e.g. "bean") against a leaf-specific rule dropped the
    entire subtree before the leaf ("bean.stringField") was ever evaluated —
    diverging from ParametersInterceptor, which only evaluates complete leaf
    names. For arrays it also meant the accepted allowlist judged the container
    name instead of the element path.
    
    Split the per-key gate: length, excluded patterns, @StrutsParameter
    authorization and property filters stay per-node (exclusion is prefix-safe
    and authorization is intentionally hierarchical); accepted patterns and
    ParameterNameAware move to leaf keys only, including scalar array elements
    at their indexed path ("items[0]"). This reproduces the flat-path semantics
    exactly. Excluded/include-property behavior is unchanged.
    
    Tests: nested-object leaf populates under a leaf-targeting accepted pattern
    and a ParameterNameAware action that rejects the intermediate node; accepted
    patterns now apply to the array element path; nested include-property
    filtering still works.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-4858 fix(json): apply per-node checks to scalar array elements
    
    Scalar list elements were gated only by the leaf name-allowlist and value
    checks, skipping the per-node checks (length, excluded patterns,
    @StrutsParameter authorization, property filters). That left the JSON path
    more permissive than ParametersInterceptor, which evaluates all of these
    against the full indexed name "items[0]".
    
    Apply isAcceptableNode(elementPrefix, ...) to scalar list elements so an
    element is gated exactly as the flat path gates "items[0]". Note this makes
    scalar-list @StrutsParameter authorization use the element path (depth 1,
    read method) rather than only the container (depth 0), matching the flat
    path.
    
    Tests: excluded name pattern and @StrutsParameter authorization now apply at
    the list element path.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-4858 refactor(json): extract keyTypeName helper to lower cognitive 
complexity
    
    Move the non-String-key logging ternary out of 
filterUnacceptableKeysRecursive
    into a keyTypeName helper. Pure extraction, no behavior change; drops the
    method's cognitive complexity from 17 to 14, under Sonar's S3776 threshold.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../org/apache/struts2/json/JSONInterceptor.java   | 63 +++++++++++----
 .../apache/struts2/json/JSONInterceptorTest.java   | 94 ++++++++++++++++++++--
 .../struts2/json/ParameterAwareTestAction.java     | 13 ++-
 3 files changed, 149 insertions(+), 21 deletions(-)

diff --git 
a/plugins/json/src/main/java/org/apache/struts2/json/JSONInterceptor.java 
b/plugins/json/src/main/java/org/apache/struts2/json/JSONInterceptor.java
index 804542399..9386a618d 100644
--- a/plugins/json/src/main/java/org/apache/struts2/json/JSONInterceptor.java
+++ b/plugins/json/src/main/java/org/apache/struts2/json/JSONInterceptor.java
@@ -233,17 +233,24 @@ public class JSONInterceptor extends AbstractInterceptor {
                 // Defensive: a custom JSONReader could produce non-String 
keys. Skip — we cannot
                 // construct a parameter path for filtering, and JSONPopulator 
wouldn't bind these anyway.
                 LOG.debug("Skipping JSON entry with non-String key [{}] of 
type [{}] under prefix [{}]",
-                        entry.getKey(), entry.getKey() == null ? "null" : 
entry.getKey().getClass().getName(), prefix);
+                        entry.getKey(), keyTypeName(entry.getKey()), prefix);
                 continue;
             }
             String fullPath = prefix.isEmpty() ? key : prefix + "." + key;
 
-            if (!isAcceptableKey(fullPath, target, action)) {
+            Object value = entry.getValue();
+            boolean leaf = !(value instanceof Map) && !(value instanceof 
java.util.List);
+
+            // Per-node checks (length, excluded patterns, @StrutsParameter 
authorization, property
+            // filters) apply to every node. Name-allowlist checks (accepted 
patterns,
+            // ParameterNameAware) apply only at leaf keys, so an intermediate 
node is never gated by
+            // a pattern written for a leaf path — mirroring 
ParametersInterceptor, which only ever
+            // evaluates complete leaf names.
+            if (!isAcceptableNode(fullPath, target, action) || (leaf && 
!isAcceptableLeafName(fullPath, action))) {
                 it.remove();
                 continue;
             }
 
-            Object value = entry.getValue();
             if (value instanceof Map) {
                 filterUnacceptableKeysRecursive((Map) value, fullPath, target, 
action);
             } else if (value instanceof java.util.List) {
@@ -254,6 +261,10 @@ public class JSONInterceptor extends AbstractInterceptor {
         }
     }
 
+    private static String keyTypeName(Object key) {
+        return key == null ? "null" : key.getClass().getName();
+    }
+
     @SuppressWarnings("rawtypes")
     private void filterUnacceptableList(java.util.List list, String prefix, 
Object target, Object action) {
         // Use prefix+"[0]" so list element properties pick up one extra '[' 
in their path,
@@ -266,15 +277,25 @@ public class JSONInterceptor extends AbstractInterceptor {
                 filterUnacceptableKeysRecursive((Map) item, elementPrefix, 
target, action);
             } else if (item instanceof java.util.List) {
                 filterUnacceptableList((java.util.List) item, elementPrefix, 
target, action);
-            // Scalar list elements are value-checked only; their parent key 
already passed name/authorization checks.
-            } else if (!isAcceptableValue(elementPrefix, item, action)) {
+            // Scalar list elements are full leaf binding targets: apply the 
per-node checks, the leaf
+            // name-allowlist checks and the value checks at the element path 
(e.g. "items[0]"), so a
+            // scalar element is gated exactly as ParametersInterceptor gates 
the flat name "items[0]".
+            } else if (!isAcceptableNode(elementPrefix, target, action)
+                    || !isAcceptableLeafName(elementPrefix, action)
+                    || !isAcceptableValue(elementPrefix, item, action)) {
                 it.remove();
             }
         }
     }
 
-    @SuppressWarnings("rawtypes")
-    private boolean isAcceptableKey(String fullPath, Object target, Object 
action) {
+    /**
+     * Checks applied to every node of the JSON tree (both intermediate 
objects/arrays and leaves):
+     * key length, excluded name patterns, {@code @StrutsParameter} 
authorization, and — when
+     * enabled — the interceptor's own property filters. Excluded patterns are 
prefix-safe and
+     * authorization is intentionally hierarchical (a parent must be 
authorized for a child to be
+     * reachable), so these are correct to evaluate at intermediate nodes.
+     */
+    private boolean isAcceptableNode(String fullPath, Object target, Object 
action) {
         if (fullPath.length() > paramNameMaxLength) {
             LOG.warn("JSON body parameter [{}] is too long, allowed length is 
[{}]; rejected", fullPath, paramNameMaxLength);
             return false;
@@ -283,19 +304,11 @@ public class JSONInterceptor extends AbstractInterceptor {
             LOG.warn("JSON body parameter [{}] matches an excluded pattern; 
rejected", fullPath);
             return false;
         }
-        if (acceptedPatterns != null && 
!acceptedPatterns.isAccepted(fullPath).isAccepted()) {
-            LOG.warn("JSON body parameter [{}] does not match any accepted 
pattern; rejected", fullPath);
-            return false;
-        }
         if (!parameterAuthorizer.isAuthorized(fullPath, target, action)) {
             LOG.warn("JSON body parameter [{}] rejected by @StrutsParameter 
authorization on [{}]",
                     fullPath, target.getClass().getName());
             return false;
         }
-        if (action instanceof ParameterNameAware nameAware && 
!nameAware.acceptableParameterName(fullPath)) {
-            LOG.debug("JSON body parameter [{}] rejected by ParameterNameAware 
action", fullPath);
-            return false;
-        }
         if (applyPropertyFiltersToInput && 
!isAcceptedByPropertyFilters(fullPath)) {
             LOG.debug("JSON body parameter [{}] rejected by 
excludeProperties/includeProperties on input", fullPath);
             return false;
@@ -303,6 +316,26 @@ public class JSONInterceptor extends AbstractInterceptor {
         return true;
     }
 
+    /**
+     * Name-allowlist checks applied only at leaf keys (scalar values, 
including scalar array
+     * elements): accepted name patterns and the {@link ParameterNameAware} 
action callback. These
+     * are deliberately not applied to intermediate nodes: their 
patterns/decisions target the full
+     * dotted binding path, and gating an intermediate node against a 
leaf-specific rule would drop
+     * the whole subtree before the leaf is ever evaluated. This matches 
ParametersInterceptor, which
+     * only ever evaluates complete leaf names.
+     */
+    private boolean isAcceptableLeafName(String fullPath, Object action) {
+        if (acceptedPatterns != null && 
!acceptedPatterns.isAccepted(fullPath).isAccepted()) {
+            LOG.warn("JSON body parameter [{}] does not match any accepted 
pattern; rejected", fullPath);
+            return false;
+        }
+        if (action instanceof ParameterNameAware nameAware && 
!nameAware.acceptableParameterName(fullPath)) {
+            LOG.debug("JSON body parameter [{}] rejected by ParameterNameAware 
action", fullPath);
+            return false;
+        }
+        return true;
+    }
+
     private boolean isAcceptedByPropertyFilters(String fullPath) {
         if (excludeProperties != null) {
             for (Pattern pattern : excludeProperties) {
diff --git 
a/plugins/json/src/test/java/org/apache/struts2/json/JSONInterceptorTest.java 
b/plugins/json/src/test/java/org/apache/struts2/json/JSONInterceptorTest.java
index 4aa27563a..32853534a 100644
--- 
a/plugins/json/src/test/java/org/apache/struts2/json/JSONInterceptorTest.java
+++ 
b/plugins/json/src/test/java/org/apache/struts2/json/JSONInterceptorTest.java
@@ -762,11 +762,11 @@ public class JSONInterceptorTest extends StrutsTestCase {
         JSONInterceptor interceptor = createInterceptor();
         org.apache.struts2.security.DefaultAcceptedPatternsChecker accepted =
                 new 
org.apache.struts2.security.DefaultAcceptedPatternsChecker();
-        // Accept the intermediate node "bean" and the nested leaf 
"bean.stringField", but not
-        // "bean.intField". The intermediate node must itself match an 
accepted pattern, otherwise
-        // the whole subtree is dropped before the leaf is ever visited 
(accepted name patterns are
-        // raw full-match regexes with no hierarchy expansion, unlike include 
patterns).
-        accepted.setAcceptedPatterns("bean(\\.stringField)?");
+        // Leaf-targeting pattern: matches only the nested leaf 
"bean.stringField", not the
+        // intermediate node "bean". Accepted patterns are evaluated at leaf 
keys, so the
+        // intermediate node is not gated and the matching leaf still 
populates while the
+        // non-matching sibling "bean.intField" is dropped.
+        accepted.setAcceptedPatterns("bean\\.stringField");
         interceptor.setAcceptedPatterns(accepted);
         TestAction action = new TestAction();
 
@@ -780,6 +780,90 @@ public class JSONInterceptorTest extends StrutsTestCase {
         assertEquals(0, action.getBean().getIntField());
     }
 
+    public void testAcceptedNamePatternAppliesToListElementPath() throws 
Exception {
+        this.request.setContent("{\"list\": [\"x\", \"y\"]}".getBytes());
+        this.request.addHeader("Content-Type", "application/json");
+
+        JSONInterceptor interceptor = createInterceptor();
+        org.apache.struts2.security.DefaultAcceptedPatternsChecker accepted =
+                new 
org.apache.struts2.security.DefaultAcceptedPatternsChecker();
+        // Matches the container name "list" but not the element path 
"list[0]". Because accepted
+        // patterns are evaluated at the leaf (the scalar element path), the 
elements are rejected
+        // even though the container name matches — mirroring the flat 
ParametersInterceptor path,
+        // which would evaluate "list[0]" and reject it.
+        accepted.setAcceptedPatterns("list");
+        interceptor.setAcceptedPatterns(accepted);
+        TestAction action = new TestAction();
+
+        this.invocation.setAction(action);
+        this.invocation.getStack().push(action);
+
+        interceptor.intercept(this.invocation);
+
+        assertTrue(action.getList() == null || action.getList().isEmpty());
+    }
+
+    public void testExcludedNamePatternAppliesToListElementPath() throws 
Exception {
+        this.request.setContent("{\"list\": [\"x\", \"y\"]}".getBytes());
+        this.request.addHeader("Content-Type", "application/json");
+
+        JSONInterceptor interceptor = createInterceptor();
+        org.apache.struts2.security.DefaultExcludedPatternsChecker excluded =
+                new 
org.apache.struts2.security.DefaultExcludedPatternsChecker();
+        // Excludes the element path "list[0]" but not the container "list". 
Scalar list elements are
+        // full leaf binding targets, so the per-node checks (here, excluded 
patterns) are evaluated
+        // at the element path — mirroring the flat ParametersInterceptor 
path, which would exclude
+        // "list[0]".
+        excluded.setExcludedPatterns("list\\[0\\]");
+        interceptor.setExcludedPatterns(excluded);
+        TestAction action = new TestAction();
+
+        this.invocation.setAction(action);
+        this.invocation.getStack().push(action);
+
+        interceptor.intercept(this.invocation);
+
+        assertTrue(action.getList() == null || action.getList().isEmpty());
+    }
+
+    public void testAuthorizationAppliesToListElementPath() throws Exception {
+        this.request.setContent("{\"list\": [\"x\", \"y\"]}".getBytes());
+        this.request.addHeader("Content-Type", "application/json");
+
+        JSONInterceptor interceptor = createInterceptor();
+        // Authorize the container "list" but reject the element path 
"list[0]". Scalar list elements
+        // now pass through @StrutsParameter authorization at their element 
path, so the elements are
+        // rejected even though the container is authorized.
+        interceptor.setParameterAuthorizer((parameterName, target, action) -> 
!"list[0]".equals(parameterName));
+        TestAction action = new TestAction();
+
+        this.invocation.setAction(action);
+        this.invocation.getStack().push(action);
+
+        interceptor.intercept(this.invocation);
+
+        assertTrue(action.getList() == null || action.getList().isEmpty());
+    }
+
+    public void testParameterNameAwareDoesNotRejectIntermediateNode() throws 
Exception {
+        this.request.setContent("{\"bean\": {\"stringField\": \"keep\", 
\"intField\": 42}}".getBytes());
+        this.request.addHeader("Content-Type", "application/json");
+
+        JSONInterceptor interceptor = createInterceptor();
+        // The action's acceptableParameterName rejects the intermediate node 
"bean" but accepts the
+        // nested leaves. ParameterNameAware is evaluated at leaf keys, so the 
subtree is not dropped.
+        ParameterAwareTestAction action = new ParameterAwareTestAction();
+
+        this.invocation.setAction(action);
+        this.invocation.getStack().push(action);
+
+        interceptor.intercept(this.invocation);
+
+        assertNotNull(action.getBean());
+        assertEquals("keep", action.getBean().getStringField());
+        assertEquals(42, action.getBean().getIntField());
+    }
+
     public void testExcludedValuePatternRejectsListElement() throws Exception {
         this.request.setContent("{\"list\": [\"good\", 
\"badvalue\"]}".getBytes());
         this.request.addHeader("Content-Type", "application/json");
diff --git 
a/plugins/json/src/test/java/org/apache/struts2/json/ParameterAwareTestAction.java
 
b/plugins/json/src/test/java/org/apache/struts2/json/ParameterAwareTestAction.java
index d71287cd9..eeb953ac2 100644
--- 
a/plugins/json/src/test/java/org/apache/struts2/json/ParameterAwareTestAction.java
+++ 
b/plugins/json/src/test/java/org/apache/struts2/json/ParameterAwareTestAction.java
@@ -26,6 +26,7 @@ public class ParameterAwareTestAction implements 
ParameterNameAware, ParameterVa
     private String foo;
     private String bar;
     private String baz;
+    private Bean bean;
 
     public String getFoo() {
         return foo;
@@ -51,9 +52,19 @@ public class ParameterAwareTestAction implements 
ParameterNameAware, ParameterVa
         this.baz = baz;
     }
 
+    public Bean getBean() {
+        return bean;
+    }
+
+    public void setBean(Bean bean) {
+        this.bean = bean;
+    }
+
     @Override
     public boolean acceptableParameterName(String parameterName) {
-        return !"bar".equals(parameterName);
+        // Reject the flat key "bar" and the intermediate node "bean"; nested 
leaves such as
+        // "bean.stringField" are accepted so tests can assert intermediate 
nodes are not gated.
+        return !"bar".equals(parameterName) && !"bean".equals(parameterName);
     }
 
     @Override

Reply via email to