diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java b/core/src/main/java/org/apache/struts2/StrutsConstants.java index 84b9fd16e1..eb925b422a 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 c584a4f587..f169b67f1c 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.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 void register(ContainerBuilder builder, LocatableProperties props) { 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 6c46fd2647..9eb0095928 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.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 static ContainerBuilder bootstrapFactories(ContainerBuilder builder) { .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 0000000000..d064fbf68e --- /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. + * + *

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.

+ * + *

Implementations must NOT perform OGNL ThreadAllowlist side effects — those remain specific to + * {@link ParametersInterceptor}.

+ * + * @since 7.2.0 + */ +public interface ParameterAuthorizer { + + /** + * Determines whether a parameter with the given name is authorized for injection into the given target object. + * + *

When {@code struts.parameters.requireAnnotations} is {@code false}, this method always returns {@code true} + * for backward compatibility.

+ * + * @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 293f4968a9..6173a85c65 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 excludedValuePatterns = null; private Set acceptedValuePatterns = null; + private ParameterAuthorizer parameterAuthorizer; @Inject public void setValueStackFactory(ValueStackFactory valueStackFactory) { @@ -121,6 +122,11 @@ public void setProxyService(ProxyService proxyService) { 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 @@ protected boolean isAcceptableParameterNameAware(String name, Object action) { * Checks if the Action class member corresponding to a parameter is appropriately annotated with * {@link StrutsParameter} and OGNL allowlists any necessary classes. *

+ * 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). + *

* 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 @@ protected boolean isParameterAnnotatedAndAllowlist(String name, Object action) { 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 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 0000000000..82e47717e3 --- /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. + * + *

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.

+ * + *

Unlike {@link ParametersInterceptor}, this implementation does NOT perform OGNL ThreadAllowlist side effects. + * Those remain specific to the OGNL-based parameter injection path.

+ * + * @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 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 6141786919..232f0f4a42 100644 --- a/core/src/main/resources/struts-beans.xml +++ b/core/src/main/resources/struts-beans.xml @@ -245,6 +245,9 @@ + + (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 { + @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 0040e98d3c..803e0dad5a 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 @@ public class StrutsParameterAnnotationTest { private ParametersInterceptor parametersInterceptor; + private StrutsParameterAuthorizer parameterAuthorizer; private ThreadAllowlist threadAllowlist; @@ -76,6 +77,13 @@ public void setUp() throws Exception { 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 void publicPojoMapDepthTwoMethod() { @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 void publicStrNotAnnotated_transitionMode() { @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 6511da0336..a0279f2f09 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.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 String intercept(ActionInvocation invocation) throws Exception { 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 @@ private void applyLimitsToReader() { 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 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>) 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 void setJsonUtil(JSONUtil jsonUtil) { 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 9f5c4a75f5..2203f6f290 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.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 @@ private JSONInterceptor createInterceptor() { 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 void testMaxDepthEnforcedThroughInterceptor() throws Exception { } } + 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 void testMaxElementsEnforcedThroughInterceptor() throws Exception { } } + /** + * 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 14280064f0..73f3cd7ef3 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 @@ 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. + *

+ * 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 String intercept(ActionInvocation invocation) throws Exception { 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. + * + *

{@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 2232ccb94c..a14d407eef 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 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 void testRequestWithEncodingAscii() throws Exception { 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 void testRequestWithEncodingUtf() throws Exception { 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 boolean matches(Object[] args) { 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(); + } }