Skip to content

Commit 743ce88

Browse files
lukaszlenartclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 18955b9 commit 743ce88

3 files changed

Lines changed: 142 additions & 15 deletions

File tree

plugins/json/src/main/java/org/apache/struts2/json/JSONInterceptor.java

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -235,12 +235,19 @@ private void filterUnacceptableKeysRecursive(Map json, String prefix, Object tar
235235
}
236236
String fullPath = prefix.isEmpty() ? key : prefix + "." + key;
237237

238-
if (!isAcceptableKey(fullPath, target, action)) {
238+
Object value = entry.getValue();
239+
boolean leaf = !(value instanceof Map) && !(value instanceof java.util.List);
240+
241+
// Per-node checks (length, excluded patterns, @StrutsParameter authorization, property
242+
// filters) apply to every node. Name-allowlist checks (accepted patterns,
243+
// ParameterNameAware) apply only at leaf keys, so an intermediate node is never gated by
244+
// a pattern written for a leaf path — mirroring ParametersInterceptor, which only ever
245+
// evaluates complete leaf names.
246+
if (!isAcceptableNode(fullPath, target, action) || (leaf && !isAcceptableLeafName(fullPath, action))) {
239247
it.remove();
240248
continue;
241249
}
242250

243-
Object value = entry.getValue();
244251
if (value instanceof Map) {
245252
filterUnacceptableKeysRecursive((Map) value, fullPath, target, action);
246253
} else if (value instanceof java.util.List) {
@@ -263,15 +270,23 @@ private void filterUnacceptableList(java.util.List list, String prefix, Object t
263270
filterUnacceptableKeysRecursive((Map) item, elementPrefix, target, action);
264271
} else if (item instanceof java.util.List) {
265272
filterUnacceptableList((java.util.List) item, elementPrefix, target, action);
266-
// Scalar list elements are value-checked only; their parent key already passed name/authorization checks.
267-
} else if (!isAcceptableValue(elementPrefix, item, action)) {
273+
// Scalar list elements are leaf binding targets: apply the leaf name-allowlist checks at
274+
// the element path (e.g. "items[0]") plus value checks. The container key already passed
275+
// the per-node checks in the caller.
276+
} else if (!isAcceptableLeafName(elementPrefix, action) || !isAcceptableValue(elementPrefix, item, action)) {
268277
it.remove();
269278
}
270279
}
271280
}
272281

273-
@SuppressWarnings("rawtypes")
274-
private boolean isAcceptableKey(String fullPath, Object target, Object action) {
282+
/**
283+
* Checks applied to every node of the JSON tree (both intermediate objects/arrays and leaves):
284+
* key length, excluded name patterns, {@code @StrutsParameter} authorization, and — when
285+
* enabled — the interceptor's own property filters. Excluded patterns are prefix-safe and
286+
* authorization is intentionally hierarchical (a parent must be authorized for a child to be
287+
* reachable), so these are correct to evaluate at intermediate nodes.
288+
*/
289+
private boolean isAcceptableNode(String fullPath, Object target, Object action) {
275290
if (fullPath.length() > paramNameMaxLength) {
276291
LOG.warn("JSON body parameter [{}] is too long, allowed length is [{}]; rejected", fullPath, paramNameMaxLength);
277292
return false;
@@ -280,26 +295,38 @@ private boolean isAcceptableKey(String fullPath, Object target, Object action) {
280295
LOG.warn("JSON body parameter [{}] matches an excluded pattern; rejected", fullPath);
281296
return false;
282297
}
283-
if (acceptedPatterns != null && !acceptedPatterns.isAccepted(fullPath).isAccepted()) {
284-
LOG.warn("JSON body parameter [{}] does not match any accepted pattern; rejected", fullPath);
285-
return false;
286-
}
287298
if (!parameterAuthorizer.isAuthorized(fullPath, target, action)) {
288299
LOG.warn("JSON body parameter [{}] rejected by @StrutsParameter authorization on [{}]",
289300
fullPath, target.getClass().getName());
290301
return false;
291302
}
292-
if (action instanceof ParameterNameAware nameAware && !nameAware.acceptableParameterName(fullPath)) {
293-
LOG.debug("JSON body parameter [{}] rejected by ParameterNameAware action", fullPath);
294-
return false;
295-
}
296303
if (applyPropertyFiltersToInput && !isAcceptedByPropertyFilters(fullPath)) {
297304
LOG.debug("JSON body parameter [{}] rejected by excludeProperties/includeProperties on input", fullPath);
298305
return false;
299306
}
300307
return true;
301308
}
302309

310+
/**
311+
* Name-allowlist checks applied only at leaf keys (scalar values, including scalar array
312+
* elements): accepted name patterns and the {@link ParameterNameAware} action callback. These
313+
* are deliberately not applied to intermediate nodes: their patterns/decisions target the full
314+
* dotted binding path, and gating an intermediate node against a leaf-specific rule would drop
315+
* the whole subtree before the leaf is ever evaluated. This matches ParametersInterceptor, which
316+
* only ever evaluates complete leaf names.
317+
*/
318+
private boolean isAcceptableLeafName(String fullPath, Object action) {
319+
if (acceptedPatterns != null && !acceptedPatterns.isAccepted(fullPath).isAccepted()) {
320+
LOG.warn("JSON body parameter [{}] does not match any accepted pattern; rejected", fullPath);
321+
return false;
322+
}
323+
if (action instanceof ParameterNameAware nameAware && !nameAware.acceptableParameterName(fullPath)) {
324+
LOG.debug("JSON body parameter [{}] rejected by ParameterNameAware action", fullPath);
325+
return false;
326+
}
327+
return true;
328+
}
329+
303330
private boolean isAcceptedByPropertyFilters(String fullPath) {
304331
if (excludeProperties != null) {
305332
for (Pattern pattern : excludeProperties) {

plugins/json/src/test/java/org/apache/struts2/json/JSONInterceptorTest.java

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,95 @@ public void testExcludedNamePatternRejectsNestedKey() throws Exception {
770770
assertEquals(0, action.getBean().getIntField());
771771
}
772772

773+
public void testAcceptedNamePatternRejectsNestedKey() throws Exception {
774+
this.request.setContent("{\"bean\": {\"stringField\": \"keep\", \"intField\": 42}}".getBytes());
775+
this.request.addHeader("Content-Type", "application/json");
776+
777+
JSONInterceptor interceptor = createInterceptor();
778+
org.apache.struts2.security.DefaultAcceptedPatternsChecker accepted =
779+
new org.apache.struts2.security.DefaultAcceptedPatternsChecker();
780+
// Leaf-targeting pattern: matches only the nested leaf "bean.stringField", not the
781+
// intermediate node "bean". Accepted patterns are evaluated at leaf keys, so the
782+
// intermediate node is not gated and the matching leaf still populates while the
783+
// non-matching sibling "bean.intField" is dropped.
784+
accepted.setAcceptedPatterns("bean\\.stringField");
785+
interceptor.setAcceptedPatterns(accepted);
786+
TestAction action = new TestAction();
787+
788+
this.invocation.setAction(action);
789+
this.invocation.getStack().push(action);
790+
791+
interceptor.intercept(this.invocation);
792+
793+
assertNotNull(action.getBean());
794+
assertEquals("keep", action.getBean().getStringField());
795+
assertEquals(0, action.getBean().getIntField());
796+
}
797+
798+
public void testAcceptedNamePatternAppliesToListElementPath() throws Exception {
799+
this.request.setContent("{\"list\": [\"x\", \"y\"]}".getBytes());
800+
this.request.addHeader("Content-Type", "application/json");
801+
802+
JSONInterceptor interceptor = createInterceptor();
803+
org.apache.struts2.security.DefaultAcceptedPatternsChecker accepted =
804+
new org.apache.struts2.security.DefaultAcceptedPatternsChecker();
805+
// Matches the container name "list" but not the element path "list[0]". Because accepted
806+
// patterns are evaluated at the leaf (the scalar element path), the elements are rejected
807+
// even though the container name matches — mirroring the flat ParametersInterceptor path,
808+
// which would evaluate "list[0]" and reject it.
809+
accepted.setAcceptedPatterns("list");
810+
interceptor.setAcceptedPatterns(accepted);
811+
TestAction action = new TestAction();
812+
813+
this.invocation.setAction(action);
814+
this.invocation.getStack().push(action);
815+
816+
interceptor.intercept(this.invocation);
817+
818+
assertTrue(action.getList() == null || action.getList().isEmpty());
819+
}
820+
821+
public void testParameterNameAwareDoesNotRejectIntermediateNode() throws Exception {
822+
this.request.setContent("{\"bean\": {\"stringField\": \"keep\", \"intField\": 42}}".getBytes());
823+
this.request.addHeader("Content-Type", "application/json");
824+
825+
JSONInterceptor interceptor = createInterceptor();
826+
// The action's acceptableParameterName rejects the intermediate node "bean" but accepts the
827+
// nested leaves. ParameterNameAware is evaluated at leaf keys, so the subtree is not dropped.
828+
ParameterAwareTestAction action = new ParameterAwareTestAction();
829+
830+
this.invocation.setAction(action);
831+
this.invocation.getStack().push(action);
832+
833+
interceptor.intercept(this.invocation);
834+
835+
assertNotNull(action.getBean());
836+
assertEquals("keep", action.getBean().getStringField());
837+
assertEquals(42, action.getBean().getIntField());
838+
}
839+
840+
public void testIncludePropertiesAppliedToNestedInputWhenEnabled() throws Exception {
841+
this.request.setContent("{\"bean\": {\"stringField\": \"keep\", \"intField\": 42}}".getBytes());
842+
this.request.addHeader("Content-Type", "application/json");
843+
844+
JSONInterceptor interceptor = createInterceptor();
845+
interceptor.setApplyPropertyFiltersToInput(true);
846+
// Include patterns expand across the hierarchy: "bean.stringField" compiles patterns for
847+
// both the intermediate node "bean" and the leaf, so the nested leaf populates while the
848+
// excluded sibling "bean.intField" is dropped.
849+
interceptor.setIncludeProperties("bean\\.stringField");
850+
TestAction action = new TestAction();
851+
852+
this.invocation.setAction(action);
853+
this.invocation.getStack().push(action);
854+
855+
interceptor.intercept(this.invocation);
856+
857+
assertNotNull(action.getBean());
858+
assertEquals("keep", action.getBean().getStringField());
859+
assertEquals(0, action.getBean().getIntField());
860+
}
861+
773862
public void testExcludedValuePatternRejectsListElement() throws Exception {
774863
this.request.setContent("{\"list\": [\"good\", \"badvalue\"]}".getBytes());
775864
this.request.addHeader("Content-Type", "application/json");

plugins/json/src/test/java/org/apache/struts2/json/ParameterAwareTestAction.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public class ParameterAwareTestAction implements ParameterNameAware, ParameterVa
2626
private String foo;
2727
private String bar;
2828
private String baz;
29+
private Bean bean;
2930

3031
public String getFoo() {
3132
return foo;
@@ -51,9 +52,19 @@ public void setBaz(String baz) {
5152
this.baz = baz;
5253
}
5354

55+
public Bean getBean() {
56+
return bean;
57+
}
58+
59+
public void setBean(Bean bean) {
60+
this.bean = bean;
61+
}
62+
5463
@Override
5564
public boolean acceptableParameterName(String parameterName) {
56-
return !"bar".equals(parameterName);
65+
// Reject the flat key "bar" and the intermediate node "bean"; nested leaves such as
66+
// "bean.stringField" are accepted so tests can assert intermediate nodes are not gated.
67+
return !"bar".equals(parameterName) && !"bean".equals(parameterName);
5768
}
5869

5970
@Override

0 commit comments

Comments
 (0)