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 c3a887085 WW-5624: Enforce @StrutsParameter on JSON/REST body
deserialization (#1657)
c3a887085 is described below
commit c3a887085d4b11dd4290cc0fd1ef34a1707baa1f
Author: quactv <[email protected]>
AuthorDate: Fri May 1 15:46:38 2026 +0700
WW-5624: Enforce @StrutsParameter on JSON/REST body deserialization (#1657)
* WW-5624 fix(security): enforce @StrutsParameter on JSON/REST body
deserialization
Extract ParameterAuthorizer service from ParametersInterceptor to share
@StrutsParameter annotation enforcement across all input channels.
The json-plugin (JSONInterceptor) and rest-plugin (ContentTypeInterceptor)
previously bypassed @StrutsParameter checks when deserializing request
bodies, allowing mass assignment even when
struts.parameters.requireAnnotations=true.
Changes:
- New ParameterAuthorizer interface and DefaultParameterAuthorizer impl
- JSONInterceptor: filter unauthorized Map keys before populateObject()
- ContentTypeInterceptor: two-phase deserialization (fresh instance then
copy authorized properties) when requireAnnotations=true; direct
deserialization for backward compat when disabled
- OGNL ThreadAllowlist side effects remain in ParametersInterceptor only
- Full DI wiring: struts-beans.xml + StrutsBeanSelectionProvider +
DefaultConfiguration
- 15 new unit tests for ParameterAuthorizer, 2 for JSON plugin,
2 for REST plugin; 32 existing regression tests verified
* WW-5624 address review feedback from lukaszlenart on PR #1657
1. Rename DefaultParameterAuthorizer → StrutsParameterAuthorizer
per Struts naming convention (inline suggestion)
2. Narrow ModelDriven exemption: require action instanceof ModelDriven
before exempting target from @StrutsParameter checks. Prevents
non-ModelDriven root objects (e.g. JSONInterceptor.root) from
bypassing annotation enforcement.
3. Recursive JSON key filtering: filterUnauthorizedKeys() now recurses
into nested Maps and Lists, building dot-notation paths (e.g.
"address.city") for path-aware @StrutsParameter(depth=N) checks.
4. Deep REST property copy: copyAuthorizedProperties() now recurses
into nested bean types with path-aware authorization. Collections,
Maps, primitives, and java.lang/java.time types are copied directly.
5. Null-skip semantics preserved and documented: in two-phase
deserialization, null in freshInstance is indistinguishable from
"not present in request" — clearing would destroy pre-initialized
fields. Kept as intentional design choice with inline documentation.
6. No-arg constructor fallback: when target class lacks a no-arg
constructor, falls back to single-phase deserialization with
post-scrub of unauthorized properties, preserving backward compat.
7. New regression tests:
- Non-ModelDriven target with different object (must not exempt)
- Nested JSON keys recursively filtered
- Non-action root object still checked by authorizer
All 280+ core tests, 124 JSON tests, 76 REST tests pass with 0 regressions.
* WW-5624: v3 — fix indexed-path depth parity with ParametersInterceptor
Four gaps identified by lukaszlenart's April 10 review are now fully
addressed:
1. JSON filterUnauthorizedList: pass prefix+"[0]" instead of bare prefix so
that list element properties gain one extra '[' in their path — e.g.
"publicPojoListDepthOne[0].key" (depth=2) is now correctly rejected when
@StrutsParameter(depth=1), matching ParametersInterceptor semantics.
Also recurse into nested List<List<Map>> via an else-if branch.
2. REST copyAuthorizedProperties: add authTarget parameter (always = root
action/model, passed unchanged through all recursion levels).
isAuthorized() now checks the full path against the root class, so
"address.city" is looked up on the action, not on the Address object.
3. REST Collection/Map/array deep authorization: replaced the as-is copy
with deepCopyAuthorizedCollection(), deepCopyAuthorizedMap(), and
deepCopyAuthorizedArray() helpers — each iterates elements with
path+"[0]" prefix, authorizing every complex element individually.
No-arg fallback skips the element rather than copying an unfiltered
object graph (security fix over plan's original as-is suggestion).
4. REST scrubUnauthorizedProperties: now fully recursive via
scrubUnauthorizedPropertiesRecursive() — visits nested beans,
collection elements, and map values with authTarget always pointing
to the root. Includes identity-based visited-set to guard against
circular reference cycles.
Tests: core 2920 + json 124 + rest 76 = 3120, 0 failures.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* WW-5624: v3.1 — fix collection type, identity set, isNestedBeanType
coverage
Three correctness/security issues identified by independent review:
1. deepCopyAuthorizedCollection/deepCopyAuthorizedMap type preservation:
Previously always returned ArrayList/LinkedHashMap. If the action field
is typed Set<Pojo> or SortedMap<K,V>, writeMethod.invoke would throw
IllegalArgumentException. Now: SortedSet→TreeSet, Set→LinkedHashSet,
List→ArrayList; SortedMap→TreeMap, Map→LinkedHashMap.
2. scrubUnauthorizedPropertiesRecursive visited-set identity safety:
Replaced Set<Integer>+System.identityHashCode (not collision-safe) with
Collections.newSetFromMap(new IdentityHashMap<>()) which uses reference
equality (==). A hash collision could have caused a valid nested object
to be skipped, leaving unauthorized properties un-scrubbed.
3. isNestedBeanType now excludes all standard-library leaf packages:
java.util.* non-Collection/Map types (UUID, Currency, Locale, Date),
java.time.* (all temporal types, not just Temporal subinterface),
java.net.*, java.io.*, java.nio.*. Previously UUID etc. would return
true, causing the code to recurse into their internal fields and silently
drop the value when no @StrutsParameter annotation matched.
Tests: json 124 + rest 76 = 200, 0 failures.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* WW-5624: v4 — close bulk-copy fallback, reject body when no no-arg ctor
Two remaining gaps addressed per lukaszlenart's April 11 review:
1. copyAuthorizedProperties bulk-copy fallback removed:
When a nested target bean is null and createFreshInstance fails (no
no-arg constructor), the previous code fell back to
writeMethod.invoke(target, sourceValue) — copying the whole nested
object graph without per-path authorization. Now logs a warning and
skips the property entirely (same policy as deepCopyAuthorizedCollection
elements with no no-arg constructor).
2. Top-level no-arg constructor fallback changed from scrub to reject:
When requireAnnotations=true and the target class has no no-arg
constructor, body deserialization is now rejected entirely
(handler.toObject is never called). The previous best-effort scrub
path could not guarantee that all nested unauthorized properties were
nulled out. scrubUnauthorizedProperties and its recursive helper are
removed as dead code.
Tests: rest 76, 0 failures.
---------
Co-authored-by: tranquac <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
---
.../java/org/apache/struts2/StrutsConstants.java | 7 +
.../config/StrutsBeanSelectionProvider.java | 2 +
.../struts2/config/impl/DefaultConfiguration.java | 3 +
.../interceptor/parameter/ParameterAuthorizer.java | 48 ++++
.../parameter/ParametersInterceptor.java | 69 ++++-
.../parameter/StrutsParameterAuthorizer.java | 232 +++++++++++++++++
core/src/main/resources/struts-beans.xml | 3 +
.../parameter/ParameterAuthorizerTest.java | 236 +++++++++++++++++
.../parameter/StrutsParameterAnnotationTest.java | 10 +
.../org/apache/struts2/json/JSONInterceptor.java | 55 ++++
.../apache/struts2/json/JSONInterceptorTest.java | 106 ++++++++
.../struts2/rest/ContentTypeInterceptor.java | 287 ++++++++++++++++++++-
.../struts2/rest/ContentTypeInterceptorTest.java | 82 ++++++
13 files changed, 1130 insertions(+), 10 deletions(-)
diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java
b/core/src/main/java/org/apache/struts2/StrutsConstants.java
index 84b9fd16e..eb925b422 100644
--- a/core/src/main/java/org/apache/struts2/StrutsConstants.java
+++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java
@@ -551,6 +551,13 @@ public final class StrutsConstants {
*/
public static final String STRUTS_PROXYSERVICE = "struts.proxyService";
+ /**
+ * The {@link
org.apache.struts2.interceptor.parameter.ParameterAuthorizer} implementation
class.
+ *
+ * @since 7.2.0
+ */
+ public static final String STRUTS_PARAMETER_AUTHORIZER =
"struts.parameterAuthorizer";
+
/**
* 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 c584a4f58..f169b67f1 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.ParameterAuthorizer;
import org.apache.struts2.util.ProxyService;
import org.apache.struts2.util.TextParser;
import org.apache.struts2.util.ValueStackFactory;
@@ -446,6 +447,7 @@ public class StrutsBeanSelectionProvider extends
AbstractBeanSelectionProvider {
alias(BeanInfoCacheFactory.class,
StrutsConstants.STRUTS_OGNL_BEANINFO_CACHE_FACTORY, builder, props,
Scope.SINGLETON);
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(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 6c46fd264..9eb009592 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.StrutsParameterAuthorizer;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.apache.struts2.util.StrutsProxyService;
import org.apache.struts2.util.OgnlTextParser;
import org.apache.struts2.util.PatternMatcher;
@@ -406,6 +408,7 @@ public class DefaultConfiguration implements Configuration {
.factory(BeanInfoCacheFactory.class,
DefaultOgnlBeanInfoCacheFactory.class, Scope.SINGLETON)
.factory(ProxyCacheFactory.class,
StrutsProxyCacheFactory.class, Scope.SINGLETON)
.factory(ProxyService.class, StrutsProxyService.class,
Scope.SINGLETON)
+ .factory(ParameterAuthorizer.class,
StrutsParameterAuthorizer.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/parameter/ParameterAuthorizer.java
b/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizer.java
new file mode 100644
index 000000000..d064fbf68
--- /dev/null
+++
b/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizer.java
@@ -0,0 +1,48 @@
+/*
+ * 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;
+
+/**
+ * Service for determining whether a given parameter name is authorized for
injection into a target object, based on
+ * {@link StrutsParameter} annotation presence and depth.
+ *
+ * <p>This service extracts the authorization logic from {@link
ParametersInterceptor} so that it can be reused by other
+ * input channels (e.g. JSON plugin, REST plugin) that also need to enforce
{@code @StrutsParameter} rules.</p>
+ *
+ * <p>Implementations must NOT perform OGNL ThreadAllowlist side effects —
those remain specific to
+ * {@link ParametersInterceptor}.</p>
+ *
+ * @since 7.2.0
+ */
+public interface ParameterAuthorizer {
+
+ /**
+ * Determines whether a parameter with the given name is authorized for
injection into the given target object.
+ *
+ * <p>When {@code struts.parameters.requireAnnotations} is {@code false},
this method always returns {@code true}
+ * for backward compatibility.</p>
+ *
+ * @param parameterName the parameter name (e.g. "name", "address.city",
"items[0].name")
+ * @param target the object receiving the parameter value (the
action, or the model for ModelDriven actions)
+ * @param action the action instance; used to detect ModelDriven
exemption (when {@code target != action},
+ * the target is the model and is exempt from
annotation requirements)
+ * @return {@code true} if the parameter is authorized for injection,
{@code false} otherwise
+ */
+ boolean isAuthorized(String parameterName, Object target, Object 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 293f4968a..6173a85c6 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
@@ -100,6 +100,7 @@ public class ParametersInterceptor extends
MethodFilterInterceptor {
private AcceptedPatternsChecker acceptedPatterns;
private Set<Pattern> excludedValuePatterns = null;
private Set<Pattern> acceptedValuePatterns = null;
+ private ParameterAuthorizer parameterAuthorizer;
@Inject
public void setValueStackFactory(ValueStackFactory valueStackFactory) {
@@ -121,6 +122,11 @@ public class ParametersInterceptor extends
MethodFilterInterceptor {
this.proxyService = proxyService;
}
+ @Inject
+ public void setParameterAuthorizer(ParameterAuthorizer
parameterAuthorizer) {
+ this.parameterAuthorizer = parameterAuthorizer;
+ }
+
@Inject(StrutsConstants.STRUTS_DEVMODE)
public void setDevMode(String mode) {
this.devMode = BooleanUtils.toBoolean(mode);
@@ -352,6 +358,9 @@ public class ParametersInterceptor extends
MethodFilterInterceptor {
* Checks if the Action class member corresponding to a parameter is
appropriately annotated with
* {@link StrutsParameter} and OGNL allowlists any necessary classes.
* <p>
+ * Authorization is delegated to {@link ParameterAuthorizer}. If
authorized, OGNL allowlisting is performed as a
+ * second pass (this is specific to the OGNL-based parameter injection
path and not shared with other input channels).
+ * <p>
* Note that this logic relies on the use of {@link
DefaultAcceptedPatternsChecker#NESTING_CHARS} and may also
* be adversely impacted by the use of custom OGNL property accessors.
*/
@@ -360,23 +369,67 @@ public class ParametersInterceptor extends
MethodFilterInterceptor {
return true;
}
- long paramDepth = name.codePoints().mapToObj(c -> (char)
c).filter(NESTING_CHARS::contains).count();
+ // Resolve target for ModelDriven: if the ValueStack peek is different
from the action, it's the model
+ Object target = action;
+ if (action instanceof ModelDriven<?>) {
+ Object stackTop =
ActionContext.getContext().getValueStack().peek();
+ if (!stackTop.equals(action)) {
+ target = stackTop;
+ }
+ }
- if (action instanceof ModelDriven<?> &&
!ActionContext.getContext().getValueStack().peek().equals(action)) {
- LOG.debug("Model driven Action detected, exempting from
@StrutsParameter annotation requirement");
- return true;
+ // Delegate authorization check to shared ParameterAuthorizer (no OGNL
side effects)
+ if (!parameterAuthorizer.isAuthorized(name, target, action)) {
+ return false;
}
- if (requireAnnotationsTransitionMode && paramDepth == 0) {
- LOG.debug("Annotation transition mode enabled, exempting
non-nested parameter [{}] from @StrutsParameter annotation requirement", name);
- return true;
+ // 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);
}
+ 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);
- return hasValidAnnotatedMember(normalisedRootProperty, action,
paramDepth);
+ 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
+ }
}
/**
diff --git
a/core/src/main/java/org/apache/struts2/interceptor/parameter/StrutsParameterAuthorizer.java
b/core/src/main/java/org/apache/struts2/interceptor/parameter/StrutsParameterAuthorizer.java
new file mode 100644
index 000000000..82e47717e
--- /dev/null
+++
b/core/src/main/java/org/apache/struts2/interceptor/parameter/StrutsParameterAuthorizer.java
@@ -0,0 +1,232 @@
+/*
+ * 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.commons.lang3.BooleanUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.ModelDriven;
+import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.inject.Inject;
+import org.apache.struts2.ognl.OgnlUtil;
+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.util.Arrays;
+import java.util.Optional;
+
+import static java.lang.String.format;
+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;
+import static org.apache.struts2.util.DebugUtils.notifyDeveloperOfError;
+
+/**
+ * Default implementation of {@link ParameterAuthorizer} that checks {@link
StrutsParameter} annotations on the target
+ * object's members to determine whether a parameter is authorized for
injection.
+ *
+ * <p>This implementation extracts the authorization logic from {@link
ParametersInterceptor} so that it can be shared
+ * with other input channels (JSON plugin, REST plugin) without duplicating
code.</p>
+ *
+ * <p>Unlike {@link ParametersInterceptor}, this implementation does NOT
perform OGNL ThreadAllowlist side effects.
+ * Those remain specific to the OGNL-based parameter injection path.</p>
+ *
+ * @since 7.2.0
+ */
+public class StrutsParameterAuthorizer implements ParameterAuthorizer {
+
+ private static final Logger LOG =
LogManager.getLogger(StrutsParameterAuthorizer.class);
+
+ private boolean requireAnnotations = false;
+ private boolean requireAnnotationsTransitionMode = false;
+ private boolean devMode = false;
+
+ private OgnlUtil ognlUtil;
+ private ProxyService proxyService;
+
+ @Inject
+ public void setOgnlUtil(OgnlUtil ognlUtil) {
+ this.ognlUtil = ognlUtil;
+ }
+
+ @Inject
+ public void setProxyService(ProxyService proxyService) {
+ this.proxyService = proxyService;
+ }
+
+ @Inject(StrutsConstants.STRUTS_DEVMODE)
+ public void setDevMode(String mode) {
+ this.devMode = BooleanUtils.toBoolean(mode);
+ }
+
+ @Inject(value = StrutsConstants.STRUTS_PARAMETERS_REQUIRE_ANNOTATIONS,
required = false)
+ public void setRequireAnnotations(String requireAnnotations) {
+ this.requireAnnotations = BooleanUtils.toBoolean(requireAnnotations);
+ }
+
+ @Inject(value =
StrutsConstants.STRUTS_PARAMETERS_REQUIRE_ANNOTATIONS_TRANSITION, required =
false)
+ public void setRequireAnnotationsTransitionMode(String transitionMode) {
+ this.requireAnnotationsTransitionMode =
BooleanUtils.toBoolean(transitionMode);
+ }
+
+ @Override
+ public boolean isAuthorized(String parameterName, Object target, Object
action) {
+ if (parameterName == null || parameterName.isEmpty()) {
+ return false;
+ }
+
+ if (!requireAnnotations) {
+ return true;
+ }
+
+ long paramDepth = parameterName.codePoints().mapToObj(c -> (char)
c).filter(NESTING_CHARS::contains).count();
+
+ // ModelDriven exemption: only exempt when the action explicitly
implements ModelDriven
+ // and the target is its model object. This prevents non-ModelDriven
root objects
+ // (e.g. JSONInterceptor's configurable rootObject) from bypassing
annotation checks.
+ if (target != action && action instanceof ModelDriven) {
+ LOG.debug("ModelDriven target detected (action implements
ModelDriven), exempting from @StrutsParameter annotation requirement");
+ return true;
+ }
+
+ // Transition mode: depth-0 (non-nested) parameters are exempt
+ if (requireAnnotationsTransitionMode && paramDepth == 0) {
+ LOG.debug("Annotation transition mode enabled, exempting
non-nested parameter [{}] from @StrutsParameter annotation requirement",
+ parameterName);
+ return true;
+ }
+
+ 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);
+
+ return hasValidAnnotatedMember(normalisedRootProperty, target,
paramDepth);
+ }
+
+ protected boolean hasValidAnnotatedMember(String rootProperty, Object
target, long paramDepth) {
+ LOG.debug("Checking target [{}] for a matching, correctly annotated
member for property [{}]",
+ target.getClass().getSimpleName(), rootProperty);
+ BeanInfo beanInfo = getBeanInfo(target);
+ if (beanInfo == null) {
+ return hasValidAnnotatedField(target, rootProperty, paramDepth);
+ }
+
+ Optional<PropertyDescriptor> propDescOpt =
Arrays.stream(beanInfo.getPropertyDescriptors())
+ .filter(desc ->
desc.getName().equals(rootProperty)).findFirst();
+ if (propDescOpt.isEmpty()) {
+ return hasValidAnnotatedField(target, rootProperty, paramDepth);
+ }
+
+ if (hasValidAnnotatedPropertyDescriptor(target, propDescOpt.get(),
paramDepth)) {
+ return true;
+ }
+
+ return hasValidAnnotatedField(target, rootProperty, paramDepth);
+ }
+
+ protected boolean hasValidAnnotatedPropertyDescriptor(Object target,
PropertyDescriptor propDesc, long paramDepth) {
+ Class<?> targetClass = ultimateClass(target);
+ Method relevantMethod = paramDepth == 0 ? propDesc.getWriteMethod() :
propDesc.getReadMethod();
+ if (relevantMethod == null) {
+ return false;
+ }
+ if (getPermittedInjectionDepth(relevantMethod) < paramDepth) {
+ String logMessage = format(
+ "Parameter injection for method [%s] on target [%s]
rejected. Ensure it is annotated with @StrutsParameter with an appropriate
'depth'.",
+ relevantMethod.getName(),
+ relevantMethod.getDeclaringClass().getName());
+ if (devMode) {
+ notifyDeveloperOfError(LOG, target, logMessage);
+ } else {
+ LOG.debug(logMessage);
+ }
+ return false;
+ }
+ LOG.debug("Success: Matching annotated method [{}] found for property
[{}] of depth [{}] on target [{}]",
+ relevantMethod.getName(), propDesc.getName(), paramDepth,
targetClass.getSimpleName());
+ return true;
+ }
+
+ protected boolean hasValidAnnotatedField(Object target, String fieldName,
long paramDepth) {
+ Class<?> targetClass = ultimateClass(target);
+ LOG.debug("No matching annotated method found for property [{}] of
depth [{}] on target [{}], now also checking for public field",
+ fieldName, paramDepth, targetClass.getSimpleName());
+ Field field;
+ try {
+ field = targetClass.getDeclaredField(fieldName);
+ } catch (NoSuchFieldException e) {
+ LOG.debug("Matching field for property [{}] not found on target
[{}]", fieldName, targetClass.getSimpleName());
+ return false;
+ }
+ if (!Modifier.isPublic(field.getModifiers())) {
+ LOG.debug("Matching field [{}] is not public on target [{}]",
field.getName(), targetClass.getSimpleName());
+ return false;
+ }
+ if (getPermittedInjectionDepth(field) < paramDepth) {
+ String logMessage = format(
+ "Parameter injection for field [%s] on target [%s]
rejected. Ensure it is annotated with @StrutsParameter with an appropriate
'depth'.",
+ field.getName(),
+ targetClass.getName());
+ if (devMode) {
+ notifyDeveloperOfError(LOG, target, logMessage);
+ } else {
+ LOG.debug(logMessage);
+ }
+ return false;
+ }
+ LOG.debug("Success: Matching annotated public field [{}] found for
property of depth [{}] on target [{}]",
+ field.getName(), paramDepth, targetClass.getSimpleName());
+ return true;
+ }
+
+ protected int getPermittedInjectionDepth(AnnotatedElement element) {
+ StrutsParameter annotation = getParameterAnnotation(element);
+ if (annotation == null) {
+ return -1;
+ }
+ return annotation.depth();
+ }
+
+ protected StrutsParameter getParameterAnnotation(AnnotatedElement element)
{
+ return element.getAnnotation(StrutsParameter.class);
+ }
+
+ protected Class<?> ultimateClass(Object target) {
+ if (proxyService.isProxy(target)) {
+ return proxyService.ultimateTargetClass(target);
+ }
+ return target.getClass();
+ }
+
+ protected BeanInfo getBeanInfo(Object target) {
+ Class<?> targetClass = ultimateClass(target);
+ try {
+ return ognlUtil.getBeanInfo(targetClass);
+ } catch (IntrospectionException e) {
+ LOG.warn("Error introspecting target {} for parameter
authorization", targetClass, e);
+ return null;
+ }
+ }
+}
diff --git a/core/src/main/resources/struts-beans.xml
b/core/src/main/resources/struts-beans.xml
index 614178691..232f0f4a4 100644
--- a/core/src/main/resources/struts-beans.xml
+++ b/core/src/main/resources/struts-beans.xml
@@ -245,6 +245,9 @@
<bean type="org.apache.struts2.util.ProxyService" name="struts"
class="org.apache.struts2.util.StrutsProxyService"
scope="singleton"/>
+ <bean type="org.apache.struts2.interceptor.parameter.ParameterAuthorizer"
name="struts"
+
class="org.apache.struts2.interceptor.parameter.StrutsParameterAuthorizer"
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/parameter/ParameterAuthorizerTest.java
b/core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizerTest.java
new file mode 100644
index 000000000..f7c16fbb9
--- /dev/null
+++
b/core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizerTest.java
@@ -0,0 +1,236 @@
+/*
+ * 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.ModelDriven;
+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.util.StrutsProxyService;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.List;
+
+import static org.apache.struts2.ognl.OgnlCacheFactory.CacheType.LRU;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link StrutsParameterAuthorizer} — verifies that the extracted
authorization logic works correctly
+ * without any OGNL ThreadAllowlist side effects.
+ */
+public class ParameterAuthorizerTest {
+
+ private StrutsParameterAuthorizer authorizer;
+
+ @Before
+ public void setUp() {
+ authorizer = new StrutsParameterAuthorizer();
+ authorizer.setRequireAnnotations(Boolean.TRUE.toString());
+
+ var ognlUtil = new OgnlUtil(
+ new DefaultOgnlExpressionCacheFactory<>(String.valueOf(1000),
LRU.toString()),
+ new DefaultOgnlBeanInfoCacheFactory<>(String.valueOf(1000),
LRU.toString()),
+ new StrutsOgnlGuard());
+ authorizer.setOgnlUtil(ognlUtil);
+
+ var proxyService = new StrutsProxyService(new
StrutsProxyCacheFactory<>("1000", "basic"));
+ authorizer.setProxyService(proxyService);
+ }
+
+ // --- requireAnnotations=false (backward compat) ---
+
+ @Test
+ public void requireAnnotationsDisabled_allAuthorized() {
+ authorizer.setRequireAnnotations(Boolean.FALSE.toString());
+ assertThat(authorizer.isAuthorized("anything", new SecureAction(), new
SecureAction())).isTrue();
+ assertThat(authorizer.isAuthorized("unannotatedProp", new
SecureAction(), new SecureAction())).isTrue();
+ }
+
+ // --- Simple property (depth 0) ---
+
+ @Test
+ public void annotatedSetter_authorized() {
+ var action = new SecureAction();
+ assertThat(authorizer.isAuthorized("name", action, action)).isTrue();
+ }
+
+ @Test
+ public void unannotatedSetter_rejected() {
+ var action = new SecureAction();
+ assertThat(authorizer.isAuthorized("role", action, action)).isFalse();
+ }
+
+ // --- Nested property (depth >= 1) ---
+
+ @Test
+ public void annotatedGetterDepthOne_nestedParam_authorized() {
+ var action = new SecureAction();
+ assertThat(authorizer.isAuthorized("address.city", action,
action)).isTrue();
+ }
+
+ @Test
+ public void annotatedGetterDepthZero_nestedParam_rejected() {
+ var action = new SecureAction();
+ assertThat(authorizer.isAuthorized("addressShallow.city", action,
action)).isFalse();
+ }
+
+ @Test
+ public void annotatedGetterDepthOne_doubleNested_rejected() {
+ var action = new SecureAction();
+ assertThat(authorizer.isAuthorized("address.city.zip", action,
action)).isFalse();
+ }
+
+ // --- Public field ---
+
+ @Test
+ public void annotatedPublicField_authorized() {
+ var action = new FieldAction();
+ assertThat(authorizer.isAuthorized("publicStr", action,
action)).isTrue();
+ }
+
+ @Test
+ public void unannotatedPublicField_rejected() {
+ var action = new FieldAction();
+ assertThat(authorizer.isAuthorized("publicStrNotAnnotated", action,
action)).isFalse();
+ }
+
+ // --- ModelDriven exemption ---
+
+ @Test
+ public void modelDriven_targetIsModel_allAuthorized() {
+ var action = new ModelAction();
+ var model = action.getModel();
+ // target != action AND action instanceof ModelDriven → model is exempt
+ assertThat(authorizer.isAuthorized("anyProperty", model,
action)).isTrue();
+ assertThat(authorizer.isAuthorized("nested.deep", model,
action)).isTrue();
+ }
+
+ @Test
+ public void nonModelDrivenAction_differentTarget_notExempt() {
+ // Regression test: when target != action but action does NOT
implement ModelDriven,
+ // the target should NOT be exempt from annotation checks.
+ var action = new SecureAction();
+ var nonActionTarget = new Pojo(); // different object, but action is
not ModelDriven
+ // Pojo has no @StrutsParameter annotations, so this should be rejected
+ assertThat(authorizer.isAuthorized("name", nonActionTarget,
action)).isFalse();
+ }
+
+ // --- Transition mode ---
+
+ @Test
+ public void transitionMode_depthZeroExempt() {
+
authorizer.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
+ var action = new SecureAction();
+ // depth-0 unannotated property should be exempt
+ assertThat(authorizer.isAuthorized("role", action, action)).isTrue();
+ }
+
+ @Test
+ public void transitionMode_depthOneNotExempt() {
+
authorizer.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
+ var action = new SecureAction();
+ // depth-1 unannotated property should NOT be exempt
+ assertThat(authorizer.isAuthorized("unannotatedNested.prop", action,
action)).isFalse();
+ }
+
+ // --- No matching member ---
+
+ @Test
+ public void nonexistentProperty_rejected() {
+ var action = new SecureAction();
+ assertThat(authorizer.isAuthorized("doesNotExist", action,
action)).isFalse();
+ }
+
+ // --- Empty/null parameter name ---
+
+ @Test
+ public void nullParameterName_rejected() {
+ var action = new SecureAction();
+ assertThat(authorizer.isAuthorized(null, action, action)).isFalse();
+ }
+
+ @Test
+ public void emptyParameterName_rejected() {
+ var action = new SecureAction();
+ assertThat(authorizer.isAuthorized("", action, action)).isFalse();
+ }
+
+ @Test
+ public void emptyParameterName_rejectedEvenWhenAnnotationsNotRequired() {
+ authorizer.setRequireAnnotations(Boolean.FALSE.toString());
+ var action = new SecureAction();
+ assertThat(authorizer.isAuthorized("", action, action)).isFalse();
+ assertThat(authorizer.isAuthorized(null, action, action)).isFalse();
+ }
+
+ // --- Inner test classes ---
+
+ public static class SecureAction {
+ private String name;
+ private String role;
+ private Address address;
+ private Address addressShallow;
+
+ @StrutsParameter
+ public void setName(String name) { this.name = name; }
+ public String getName() { return name; }
+
+ // NO @StrutsParameter — must be rejected
+ public void setRole(String role) { this.role = role; }
+ public String getRole() { return role; }
+
+ @StrutsParameter(depth = 1)
+ public Address getAddress() { return address; }
+ public void setAddress(Address address) { this.address = address; }
+
+ @StrutsParameter
+ public Address getAddressShallow() { return addressShallow; }
+ public void setAddressShallow(Address address) { this.addressShallow =
address; }
+
+ // Unannotated getter for nested param test
+ public Object getUnannotatedNested() { return null; }
+ }
+
+ public static class Address {
+ private String city;
+ public String getCity() { return city; }
+ public void setCity(String city) { this.city = city; }
+ }
+
+ public static class FieldAction {
+ @StrutsParameter
+ public String publicStr;
+
+ public String publicStrNotAnnotated;
+ }
+
+ public static class ModelAction implements ModelDriven<Pojo> {
+ @Override
+ public Pojo getModel() { return new Pojo(); }
+ }
+
+ public static class Pojo {
+ private String name;
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+ }
+}
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 0040e98d3..803e0dad5 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
@@ -56,6 +56,7 @@ import static org.mockito.Mockito.when;
public class StrutsParameterAnnotationTest {
private ParametersInterceptor parametersInterceptor;
+ private StrutsParameterAuthorizer parameterAuthorizer;
private ThreadAllowlist threadAllowlist;
@@ -76,6 +77,13 @@ public class StrutsParameterAnnotationTest {
var proxyService = new StrutsProxyService(new
StrutsProxyCacheFactory<>("1000", "basic"));
parametersInterceptor.setProxyService(proxyService);
+ var parameterAuthorizer = new StrutsParameterAuthorizer();
+ parameterAuthorizer.setOgnlUtil(ognlUtil);
+ parameterAuthorizer.setProxyService(proxyService);
+ parameterAuthorizer.setRequireAnnotations(Boolean.TRUE.toString());
+ this.parameterAuthorizer = parameterAuthorizer;
+ parametersInterceptor.setParameterAuthorizer(parameterAuthorizer);
+
NotExcludedAcceptedPatternsChecker checker =
mock(NotExcludedAcceptedPatternsChecker.class);
when(checker.isAccepted(anyString())).thenReturn(IsAccepted.yes(""));
when(checker.isExcluded(anyString())).thenReturn(IsExcluded.no(Set.of()));
@@ -360,6 +368,7 @@ public class StrutsParameterAnnotationTest {
@Test
public void publicStrNotAnnotated_transitionMode() {
parametersInterceptor.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
+
parameterAuthorizer.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
testParameter(new FieldAction(), "publicStrNotAnnotated", true);
}
@@ -369,6 +378,7 @@ public class StrutsParameterAnnotationTest {
@Test
public void publicStrNotAnnotatedMethod_transitionMode() {
parametersInterceptor.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
+
parameterAuthorizer.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
testParameter(new MethodAction(), "publicStrNotAnnotated", true);
}
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 6511da033..a0279f2f0 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
@@ -22,6 +22,7 @@ import org.apache.struts2.action.Action;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.interceptor.AbstractInterceptor;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.apache.struts2.util.ValueStack;
import org.apache.struts2.util.WildcardUtil;
import org.apache.commons.lang3.BooleanUtils;
@@ -72,6 +73,7 @@ public class JSONInterceptor extends AbstractInterceptor {
private String jsonRpcContentType = "application/json-rpc";
private JSONUtil jsonUtil;
+ private ParameterAuthorizer parameterAuthorizer;
private int maxElements = JSONReader.DEFAULT_MAX_ELEMENTS;
private int maxDepth = JSONReader.DEFAULT_MAX_DEPTH;
private int maxLength = 2_097_152; // 2MB
@@ -131,6 +133,9 @@ public class JSONInterceptor extends AbstractInterceptor {
if (rootObject == null) // model overrides action
rootObject = invocation.getStack().peek();
+ // enforce @StrutsParameter authorization on JSON body keys
+ filterUnauthorizedKeys(json, rootObject,
invocation.getAction());
+
// populate fields
populator.populateObject(rootObject, json);
} else {
@@ -200,6 +205,51 @@ public class JSONInterceptor extends AbstractInterceptor {
reader.setMaxKeyLength(maxKeyLength);
}
+ @SuppressWarnings("rawtypes")
+ private void filterUnauthorizedKeys(Map json, Object target, Object
action) {
+ filterUnauthorizedKeysRecursive(json, "", target, action);
+ }
+
+ @SuppressWarnings("rawtypes")
+ private void filterUnauthorizedKeysRecursive(Map json, String prefix,
Object target, Object action) {
+ Iterator<Map.Entry> it = json.entrySet().iterator();
+ while (it.hasNext()) {
+ Map.Entry entry = it.next();
+ String key = (String) entry.getKey();
+ String fullPath = prefix.isEmpty() ? key : prefix + "." + key;
+
+ if (!parameterAuthorizer.isAuthorized(fullPath, target, action)) {
+ LOG.warn("JSON body parameter [{}] rejected by
@StrutsParameter authorization on [{}]",
+ fullPath, target.getClass().getName());
+ it.remove();
+ continue;
+ }
+
+ // Recurse into nested Maps (JSON objects) to enforce depth-aware
authorization
+ Object value = entry.getValue();
+ if (value instanceof Map) {
+ filterUnauthorizedKeysRecursive((Map) value, fullPath, target,
action);
+ } else if (value instanceof java.util.List) {
+ filterUnauthorizedList((java.util.List) value, fullPath,
target, action);
+ }
+ }
+ }
+
+ @SuppressWarnings("rawtypes")
+ private void filterUnauthorizedList(java.util.List list, String prefix,
Object target, Object action) {
+ // Use prefix+"[0]" so that list element properties pick up one extra
'[' in their path,
+ // matching the indexed-path semantics of ParametersInterceptor (e.g.
"items[0].key" → depth 2).
+ String elementPrefix = prefix + "[0]";
+ for (Object item : list) {
+ if (item instanceof Map) {
+ filterUnauthorizedKeysRecursive((Map) item, elementPrefix,
target, action);
+ } else if (item instanceof java.util.List) {
+ // Handle nested lists (e.g. List<List<Map>>) by recursing
with the same elementPrefix
+ filterUnauthorizedList((java.util.List) item, elementPrefix,
target, action);
+ }
+ }
+ }
+
protected String readContentType(HttpServletRequest request) {
String contentType = request.getHeader("Content-Type");
LOG.debug("Content Type from request: {}", contentType);
@@ -585,6 +635,11 @@ public class JSONInterceptor extends AbstractInterceptor {
this.jsonUtil = jsonUtil;
}
+ @Inject
+ public void setParameterAuthorizer(ParameterAuthorizer
parameterAuthorizer) {
+ this.parameterAuthorizer = parameterAuthorizer;
+ }
+
@Inject(value = JSONConstants.JSON_MAX_ELEMENTS, required = false)
public void setMaxElements(String maxElements) {
this.maxElements = Integer.parseInt(maxElements);
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 9f5c4a75f..2203f6f29 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
@@ -23,6 +23,7 @@ import org.apache.struts2.mock.MockActionInvocation;
import org.apache.struts2.util.ValueStack;
import org.apache.struts2.junit.StrutsTestCase;
import org.apache.struts2.junit.util.TestUtils;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockServletContext;
@@ -47,6 +48,8 @@ public class JSONInterceptorTest extends StrutsTestCase {
jsonUtil.setReader(new StrutsJSONReader());
jsonUtil.setWriter(new StrutsJSONWriter());
interceptor.setJsonUtil(jsonUtil);
+ // Default: allow all parameters (simulates requireAnnotations=false)
+ interceptor.setParameterAuthorizer((parameterName, target, action) ->
true);
return interceptor;
}
@@ -556,6 +559,48 @@ public class JSONInterceptorTest extends StrutsTestCase {
}
}
+ public void testParameterAuthorizerRejectsUnauthorizedKeys() throws
Exception {
+ // JSON body with "foo" and "bar" keys, but authorizer only allows
"foo"
+ this.request.setContent("{\"foo\":\"allowed\",
\"bar\":\"blocked\"}".getBytes());
+ this.request.addHeader("Content-Type", "application/json");
+
+ JSONInterceptor interceptor = new JSONInterceptor();
+ JSONUtil jsonUtil = new JSONUtil();
+ jsonUtil.setReader(new StrutsJSONReader());
+ jsonUtil.setWriter(new StrutsJSONWriter());
+ interceptor.setJsonUtil(jsonUtil);
+ // Only authorize "foo", reject "bar"
+ interceptor.setParameterAuthorizer((parameterName, target, action) ->
"foo".equals(parameterName));
+ TestAction action = new TestAction();
+
+ this.invocation.setAction(action);
+ this.invocation.getStack().push(action);
+
+ interceptor.intercept(this.invocation);
+
+ // "foo" should be set, "bar" should NOT be set
+ assertEquals("allowed", action.getFoo());
+ assertNull(action.getBar());
+ }
+
+ public void testParameterAuthorizerAllowsAllWhenPermissive() throws
Exception {
+ // Same JSON body, but authorizer allows all
+ this.request.setContent("{\"foo\":\"value1\",
\"bar\":\"value2\"}".getBytes());
+ this.request.addHeader("Content-Type", "application/json");
+
+ JSONInterceptor interceptor = createInterceptor();
+ TestAction action = new TestAction();
+
+ this.invocation.setAction(action);
+ this.invocation.getStack().push(action);
+
+ interceptor.intercept(this.invocation);
+
+ // Both should be set
+ assertEquals("value1", action.getFoo());
+ assertEquals("value2", action.getBar());
+ }
+
public void testMaxElementsEnforcedThroughInterceptor() throws Exception {
// JSON object with 5 keys, set maxElements to 3
this.request.setContent("{\"a\":1, \"b\":2, \"c\":3, \"d\":4,
\"e\":5}".getBytes());
@@ -575,6 +620,67 @@ public class JSONInterceptorTest extends StrutsTestCase {
}
}
+ /**
+ * Tests that nested JSON keys are recursively checked by the parameter
authorizer.
+ * Regression test for lukaszlenart's review: nested
@StrutsParameter(depth=N) enforcement.
+ */
+ public void testNestedJsonKeysRecursivelyFiltered() throws Exception {
+ // JSON body with nested object: {"bean": {"stringField": "test",
"intField": 42}}
+ this.request.setContent("{\"bean\": {\"stringField\": \"test\",
\"intField\": 42}}".getBytes());
+ this.request.addHeader("Content-Type", "application/json");
+
+ JSONInterceptor interceptor = new JSONInterceptor();
+ JSONUtil jsonUtil = new JSONUtil();
+ jsonUtil.setReader(new StrutsJSONReader());
+ jsonUtil.setWriter(new StrutsJSONWriter());
+ interceptor.setJsonUtil(jsonUtil);
+ // Authorize "bean" (top-level) and "bean.stringField" (nested) but
reject "bean.intField"
+ interceptor.setParameterAuthorizer((parameterName, target, action) ->
+ "bean".equals(parameterName) ||
"bean.stringField".equals(parameterName));
+ TestAction action = new TestAction();
+
+ this.invocation.setAction(action);
+ this.invocation.getStack().push(action);
+
+ interceptor.intercept(this.invocation);
+
+ // bean should exist with stringField set, but intField should be
default (0)
+ assertNotNull(action.getBean());
+ assertEquals("test", action.getBean().getStringField());
+ assertEquals(0, action.getBean().getIntField());
+ }
+
+ /**
+ * Tests that when root resolves to a non-action object (not ModelDriven),
+ * annotation checks are still enforced.
+ * Regression test for lukaszlenart's review: non-action root bypass.
+ */
+ public void testNonActionRootObjectStillChecked() throws Exception {
+ this.request.setContent("{\"stringField\":\"injected\",
\"intField\":99}".getBytes());
+ this.request.addHeader("Content-Type", "application/json");
+
+ JSONInterceptor interceptor = new JSONInterceptor();
+ JSONUtil jsonUtil = new JSONUtil();
+ jsonUtil.setReader(new StrutsJSONReader());
+ jsonUtil.setWriter(new StrutsJSONWriter());
+ interceptor.setJsonUtil(jsonUtil);
+ interceptor.setRoot("bean");
+ // Reject all parameters — simulates strict requireAnnotations
+ interceptor.setParameterAuthorizer((parameterName, target, action) ->
false);
+ TestAction4 action = new TestAction4();
+
+ this.invocation.setAction(action);
+ this.invocation.getStack().push(action);
+
+ interceptor.intercept(this.invocation);
+
+ // Both fields should remain at defaults since authorizer rejected
everything
+ Bean bean = action.getBean();
+ assertNotNull(bean);
+ assertNull(bean.getStringField());
+ assertEquals(0, bean.getIntField());
+ }
+
@Override
protected void setUp() throws Exception {
super.setUp();
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 14280064f..73f3cd7ef 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
@@ -20,27 +20,67 @@ package org.apache.struts2.rest;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.ModelDriven;
+import org.apache.struts2.StrutsConstants;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.interceptor.AbstractInterceptor;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.apache.struts2.ServletActionContext;
import org.apache.struts2.rest.handler.ContentTypeHandler;
+import org.apache.commons.lang3.BooleanUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
import jakarta.servlet.http.HttpServletRequest;
+import java.beans.BeanInfo;
+import java.beans.Introspector;
+import java.beans.PropertyDescriptor;
import java.io.InputStream;
import java.io.InputStreamReader;
+import java.lang.reflect.Array;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
/**
- * Uses the content handler to apply the request body to the action
+ * Uses the content handler to apply the request body to the action.
+ * <p>
+ * When {@code struts.parameters.requireAnnotations} is enabled, only
properties annotated with
+ * {@link org.apache.struts2.interceptor.parameter.StrutsParameter} will be
populated from the request body,
+ * consistent with the parameter authorization enforced by
+ * {@link org.apache.struts2.interceptor.parameter.ParametersInterceptor} for
form/query parameters.
*/
public class ContentTypeInterceptor extends AbstractInterceptor {
+ private static final Logger LOG =
LogManager.getLogger(ContentTypeInterceptor.class);
+
private ContentTypeHandlerManager selector;
+ private ParameterAuthorizer parameterAuthorizer;
+ private boolean requireAnnotations = false;
@Inject
public void setContentTypeHandlerSelector(ContentTypeHandlerManager
selector) {
this.selector = selector;
}
+ @Inject
+ public void setParameterAuthorizer(ParameterAuthorizer
parameterAuthorizer) {
+ this.parameterAuthorizer = parameterAuthorizer;
+ }
+
+ @Inject(value = StrutsConstants.STRUTS_PARAMETERS_REQUIRE_ANNOTATIONS,
required = false)
+ public void setRequireAnnotations(String requireAnnotations) {
+ this.requireAnnotations = BooleanUtils.toBoolean(requireAnnotations);
+ }
+
public String intercept(ActionInvocation invocation) throws Exception {
HttpServletRequest request = ServletActionContext.getRequest();
ContentTypeHandler handler = selector.getHandlerForRequest(request);
@@ -54,9 +94,252 @@ public class ContentTypeInterceptor extends
AbstractInterceptor {
final String encoding = request.getCharacterEncoding();
InputStream is = request.getInputStream();
InputStreamReader reader = encoding == null ? new
InputStreamReader(is) : new InputStreamReader(is, encoding);
- handler.toObject(invocation, reader, target);
+
+ if (requireAnnotations) {
+ // Two-phase deserialization: deserialize into a fresh
instance, then copy only authorized properties.
+ // Requires a public no-arg constructor on the target class.
+ // If absent, body processing is rejected entirely — a
best-effort scrub cannot guarantee
+ // that every nested unauthorized property is nulled out, so
the safer choice is to skip.
+ Object freshInstance = createFreshInstance(target.getClass());
+ if (freshInstance != null) {
+ handler.toObject(invocation, reader, freshInstance);
+ copyAuthorizedProperties(freshInstance, target,
invocation.getAction(), target, "");
+ } else {
+ LOG.warn("REST body rejected: requireAnnotations=true but
[{}] has no no-arg constructor; "
+ + "body deserialization skipped to preserve
@StrutsParameter authorization integrity",
+ target.getClass().getName());
+ }
+ } else {
+ // Direct deserialization (backward compat when
requireAnnotations is not enabled)
+ handler.toObject(invocation, reader, target);
+ }
}
return invocation.invoke();
}
+ private Object createFreshInstance(Class<?> clazz) {
+ try {
+ return clazz.getDeclaredConstructor().newInstance();
+ } catch (ReflectiveOperationException e) {
+ LOG.debug("Cannot create fresh instance of [{}] via no-arg
constructor: {}", clazz.getName(), e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Recursively copies only authorized properties from {@code source} to
{@code target},
+ * enforcing {@code @StrutsParameter} depth semantics for nested object
graphs.
+ *
+ * <p>{@code authTarget} is always the root action/model passed unchanged
through all levels.
+ * {@code isAuthorized} uses the full dot/bracket path against the root
class, so the root
+ * target must be used — not the nested object being visited at the
current recursion depth.
+ */
+ private void copyAuthorizedProperties(
+ Object source, Object target, Object action, Object authTarget,
String prefix) throws Exception {
+ BeanInfo beanInfo = Introspector.getBeanInfo(source.getClass(),
Object.class);
+ for (PropertyDescriptor pd : beanInfo.getPropertyDescriptors()) {
+ Method readMethod = pd.getReadMethod();
+ Method writeMethod = pd.getWriteMethod();
+ if (readMethod == null || writeMethod == null) {
+ continue;
+ }
+
+ String fullPath = prefix.isEmpty() ? pd.getName() : prefix + "." +
pd.getName();
+
+ // Always check against authTarget (root action/model), never the
nested object being traversed
+ if (!parameterAuthorizer.isAuthorized(fullPath, authTarget,
action)) {
+ LOG.warn("REST body parameter [{}] rejected by
@StrutsParameter authorization on [{}]",
+ fullPath, authTarget.getClass().getName());
+ continue;
+ }
+
+ Object sourceValue = readMethod.invoke(source);
+ if (sourceValue == null) {
+ // Intentionally skip null values: in two-phase
deserialization, properties NOT present in the
+ // request body will be null in the fresh instance. Copying
null would clear pre-initialized
+ // fields on the target. This is the safer default — an
explicit JSON null and a missing field
+ // are indistinguishable after deserialization into a fresh
POJO.
+ continue;
+ }
+
+ if (isNestedBeanType(sourceValue.getClass())) {
+ // Complex bean: recurse to authorize nested fields, passing
authTarget unchanged
+ Object targetValue = readMethod.invoke(target);
+ if (targetValue == null) {
+ Object newTarget =
createFreshInstance(sourceValue.getClass());
+ if (newTarget != null) {
+ writeMethod.invoke(target, newTarget);
+ targetValue = newTarget;
+ } else {
+ // No no-arg constructor for the nested bean: skip
rather than bulk-copy the
+ // unfiltered source value, which would bypass
per-path authorization for every
+ // property underneath this node.
+ LOG.warn("REST nested bean [{}] skipped — no no-arg
constructor for [{}],"
+ + " cannot authorize its nested properties",
+ fullPath, sourceValue.getClass().getName());
+ continue;
+ }
+ }
+ copyAuthorizedProperties(sourceValue, targetValue, action,
authTarget, fullPath);
+ } else if (sourceValue instanceof Collection) {
+ writeMethod.invoke(target,
+ deepCopyAuthorizedCollection((Collection<?>)
sourceValue, fullPath, authTarget, action));
+ } else if (sourceValue instanceof Map) {
+ writeMethod.invoke(target,
+ deepCopyAuthorizedMap((Map<?, ?>) sourceValue,
fullPath, authTarget, action));
+ } else if (sourceValue.getClass().isArray()) {
+ writeMethod.invoke(target,
+ deepCopyAuthorizedArray(sourceValue, fullPath,
authTarget, action));
+ } else {
+ writeMethod.invoke(target, sourceValue);
+ }
+ }
+ }
+
+ /**
+ * Authorizes each complex element of a collection using indexed-path
semantics ({@code path[0].field}),
+ * matching {@code ParametersInterceptor} depth counting. Scalar elements
are copied directly.
+ * Elements whose class has no no-arg constructor are skipped to avoid
copying an unfiltered object graph.
+ */
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private Collection deepCopyAuthorizedCollection(
+ Collection<?> source, String collectionPath, Object authTarget,
Object action) throws Exception {
+ // Preserve the collection type so that writeMethod.invoke does not
fail when the setter
+ // parameter is typed as Set, SortedSet, etc. Fall back to ArrayList
for unrecognised types.
+ Collection result;
+ if (source instanceof SortedSet) {
+ result = new TreeSet(((SortedSet) source).comparator());
+ } else if (source instanceof Set) {
+ result = new LinkedHashSet();
+ } else {
+ result = new ArrayList();
+ }
+ for (Object element : source) {
+ if (element != null && isNestedBeanType(element.getClass())) {
+ String elementPath = collectionPath + "[0]";
+ if (!parameterAuthorizer.isAuthorized(elementPath, authTarget,
action)) {
+ LOG.warn("REST collection element [{}] rejected by
@StrutsParameter authorization", elementPath);
+ continue;
+ }
+ Object newElement = createFreshInstance(element.getClass());
+ if (newElement != null) {
+ copyAuthorizedProperties(element, newElement, action,
authTarget, elementPath);
+ result.add(newElement);
+ } else {
+ // No no-arg constructor: skip element rather than copy an
unfiltered object graph
+ LOG.warn("REST collection element [{}] skipped — no no-arg
constructor for [{}]",
+ elementPath, element.getClass().getName());
+ }
+ } else {
+ result.add(element);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Authorizes each complex map value using indexed-path semantics ({@code
path[0]}),
+ * consistent with OGNL bracket notation depth counting. Scalar values are
copied directly.
+ */
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private Map deepCopyAuthorizedMap(
+ Map<?, ?> source, String mapPath, Object authTarget, Object
action) throws Exception {
+ // Preserve the map type so that writeMethod.invoke does not fail when
the setter
+ // parameter is typed as SortedMap, TreeMap, etc.
+ Map result;
+ if (source instanceof SortedMap) {
+ result = new TreeMap(((SortedMap) source).comparator());
+ } else {
+ result = new LinkedHashMap();
+ }
+ for (Map.Entry<?, ?> entry : source.entrySet()) {
+ Object value = entry.getValue();
+ if (value != null && isNestedBeanType(value.getClass())) {
+ String valuePath = mapPath + "[0]";
+ if (!parameterAuthorizer.isAuthorized(valuePath, authTarget,
action)) {
+ LOG.warn("REST map value [{}] rejected by @StrutsParameter
authorization", valuePath);
+ continue;
+ }
+ Object newValue = createFreshInstance(value.getClass());
+ if (newValue != null) {
+ copyAuthorizedProperties(value, newValue, action,
authTarget, valuePath);
+ result.put(entry.getKey(), newValue);
+ } else {
+ LOG.warn("REST map value [{}] skipped — no no-arg
constructor for [{}]",
+ valuePath, value.getClass().getName());
+ }
+ } else {
+ result.put(entry.getKey(), value);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Authorizes each complex element of an array ({@code Pojo[]}) using
indexed-path semantics,
+ * matching {@code ParametersInterceptor} depth counting. Scalar elements
are copied directly.
+ */
+ private Object deepCopyAuthorizedArray(
+ Object sourceArray, String arrayPath, Object authTarget, Object
action) throws Exception {
+ int length = Array.getLength(sourceArray);
+ Class<?> componentType = sourceArray.getClass().getComponentType();
+ Object result = Array.newInstance(componentType, length);
+ for (int i = 0; i < length; i++) {
+ Object element = Array.get(sourceArray, i);
+ if (element != null && isNestedBeanType(element.getClass())) {
+ String elementPath = arrayPath + "[0]";
+ if (!parameterAuthorizer.isAuthorized(elementPath, authTarget,
action)) {
+ LOG.warn("REST array element [{}] rejected by
@StrutsParameter authorization", elementPath);
+ continue;
+ }
+ Object newElement = createFreshInstance(element.getClass());
+ if (newElement != null) {
+ copyAuthorizedProperties(element, newElement, action,
authTarget, elementPath);
+ Array.set(result, i, newElement);
+ } else {
+ LOG.warn("REST array element [{}] skipped — no no-arg
constructor for [{}]",
+ elementPath, element.getClass().getName());
+ }
+ } else {
+ Array.set(result, i, element);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * 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
+ * are handled directly.
+ */
+ private boolean isNestedBeanType(Class<?> clazz) {
+ if (clazz.isPrimitive() || clazz.isEnum() || clazz.isArray()) {
+ return false;
+ }
+ // Exclude standard library value/leaf types that have no meaningful
bean properties to recurse into.
+ // java.lang.*, java.math.* — primitives, String, Number subclasses,
etc.
+ // java.util.* leaf types — UUID, Currency, Locale, Date, etc. (NOT
Collection/Map which are handled separately)
+ if (clazz.getName().startsWith("java.lang.") ||
clazz.getName().startsWith("java.math.")) {
+ return false;
+ }
+ if (clazz.getName().startsWith("java.util.") &&
!Collection.class.isAssignableFrom(clazz)
+ && !Map.class.isAssignableFrom(clazz)) {
+ return false;
+ }
+ if (java.time.temporal.Temporal.class.isAssignableFrom(clazz)) {
+ return false;
+ }
+ if (clazz.getName().startsWith("java.time.")) {
+ return false;
+ }
+ if (clazz.getName().startsWith("java.net.") ||
clazz.getName().startsWith("java.io.")
+ || clazz.getName().startsWith("java.nio.")) {
+ return false;
+ }
+ if (Collection.class.isAssignableFrom(clazz) ||
Map.class.isAssignableFrom(clazz)) {
+ return false;
+ }
+ return true;
+ }
+
}
diff --git
a/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorTest.java
b/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorTest.java
index 2232ccb94..a14d407ee 100644
---
a/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorTest.java
+++
b/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorTest.java
@@ -32,12 +32,14 @@ import java.nio.charset.StandardCharsets;
import org.apache.struts2.dispatcher.mapper.ActionMapping;
import org.apache.struts2.rest.handler.ContentTypeHandler;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
import org.springframework.mock.web.MockHttpServletRequest;
public class ContentTypeInterceptorTest extends TestCase {
public void testRequestWithoutEncoding() throws Exception {
ContentTypeInterceptor interceptor = new ContentTypeInterceptor();
+ interceptor.setParameterAuthorizer((parameterName, target, action) ->
true);
ActionSupport action = new ActionSupport();
@@ -76,6 +78,7 @@ public class ContentTypeInterceptorTest extends TestCase {
final Charset charset = StandardCharsets.US_ASCII;
ContentTypeInterceptor interceptor = new ContentTypeInterceptor();
+ interceptor.setParameterAuthorizer((parameterName, target, action) ->
true);
ActionSupport action = new ActionSupport();
@@ -116,6 +119,7 @@ public class ContentTypeInterceptorTest extends TestCase {
final Charset charset = StandardCharsets.UTF_8;
ContentTypeInterceptor interceptor = new ContentTypeInterceptor();
+ interceptor.setParameterAuthorizer((parameterName, target, action) ->
true);
ActionSupport action = new ActionSupport();
@@ -151,4 +155,82 @@ public class ContentTypeInterceptorTest extends TestCase {
mockActionInvocation.verify();
mockContentTypeHandler.verify();
}
+
+ public void testRequireAnnotationsEnabled_twoPhaseDeserialization() throws
Exception {
+ ContentTypeInterceptor interceptor = new ContentTypeInterceptor();
+ interceptor.setParameterAuthorizer((parameterName, target, action) ->
false);
+ interceptor.setRequireAnnotations(Boolean.TRUE.toString());
+
+ ActionSupport action = new ActionSupport();
+
+ Mock mockActionInvocation = new Mock(ActionInvocation.class);
+ Mock mockContentTypeHandler = new Mock(ContentTypeHandler.class);
+ mockContentTypeHandler.expect("toObject", new AnyConstraintMatcher() {
+ public boolean matches(Object[] args) {
+ return true;
+ }
+ });
+ mockActionInvocation.expectAndReturn("invoke", Action.SUCCESS);
+ mockActionInvocation.expectAndReturn("getAction", action);
+ mockActionInvocation.expectAndReturn("getAction", action);
+ Mock mockContentTypeHandlerManager = new
Mock(ContentTypeHandlerManager.class);
+ mockContentTypeHandlerManager.expectAndReturn("getHandlerForRequest",
new AnyConstraintMatcher() {
+ public boolean matches(Object[] args) {
+ return true;
+ }
+ }, mockContentTypeHandler.proxy());
+ interceptor.setContentTypeHandlerSelector((ContentTypeHandlerManager)
mockContentTypeHandlerManager.proxy());
+
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setContent(new byte[] {1});
+
+ ActionContext.of()
+ .withActionMapping(new ActionMapping())
+ .withServletRequest(request)
+ .bind();
+
+ interceptor.intercept((ActionInvocation) mockActionInvocation.proxy());
+ mockContentTypeHandlerManager.verify();
+ mockActionInvocation.verify();
+ mockContentTypeHandler.verify();
+ }
+
+ public void testRequireAnnotationsEnabled_selectiveFilter() throws
Exception {
+ ContentTypeInterceptor interceptor = new ContentTypeInterceptor();
+ interceptor.setParameterAuthorizer((parameterName, target, action) ->
"name".equals(parameterName));
+ interceptor.setRequireAnnotations(Boolean.TRUE.toString());
+
+ ActionSupport action = new ActionSupport();
+
+ Mock mockActionInvocation = new Mock(ActionInvocation.class);
+ Mock mockContentTypeHandler = new Mock(ContentTypeHandler.class);
+ mockContentTypeHandler.expect("toObject", new AnyConstraintMatcher() {
+ public boolean matches(Object[] args) {
+ return true;
+ }
+ });
+ mockActionInvocation.expectAndReturn("invoke", Action.SUCCESS);
+ mockActionInvocation.expectAndReturn("getAction", action);
+ mockActionInvocation.expectAndReturn("getAction", action);
+ Mock mockContentTypeHandlerManager = new
Mock(ContentTypeHandlerManager.class);
+ mockContentTypeHandlerManager.expectAndReturn("getHandlerForRequest",
new AnyConstraintMatcher() {
+ public boolean matches(Object[] args) {
+ return true;
+ }
+ }, mockContentTypeHandler.proxy());
+ interceptor.setContentTypeHandlerSelector((ContentTypeHandlerManager)
mockContentTypeHandlerManager.proxy());
+
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setContent(new byte[] {1});
+
+ ActionContext.of()
+ .withActionMapping(new ActionMapping())
+ .withServletRequest(request)
+ .bind();
+
+ interceptor.intercept((ActionInvocation) mockActionInvocation.proxy());
+ mockContentTypeHandlerManager.verify();
+ mockActionInvocation.verify();
+ mockContentTypeHandler.verify();
+ }
}