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 20979d0ba WW-5709 fix(core): recognise fluent setters in
@StrutsParameter enforcement (#1931)
20979d0ba is described below
commit 20979d0ba5a426877d3c55459859c697f28a3ae3
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Sep 13 18:22:27 2026 +0200
WW-5709 fix(core): recognise fluent setters in @StrutsParameter enforcement
(#1931)
java.beans.Introspector only records a write method for a void setter,
while OGNL binds through any public one-argument setX method regardless
of its return type. StrutsParameterAuthorizer derived its view of what a
target can take from PropertyDescriptor.getWriteMethod(), so a fluent
setter was invisible to it: an annotation placed on one never counted,
and on a ModelDriven action an unannotated fluent setter looked declared
on neither the model nor the action and took the custom-accessor
fallback that WW-5698 left for Map-backed models.
The authorizer now resolves the depth-0 accessor the way OGNL does, from
the cached BeanInfo's method descriptors by name and arity. Where several
qualify it prefers the most-derived declaration, so an annotated override
of a generic base setter is the one judged, and among overloads at that
level an annotated one, so a convenience overload beside the annotated
setter does not turn the property away. hasValidAnnotatedPropertyDescriptor
is kept as a delegating shim, deprecated for removal under WW-5739.
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../parameter/StrutsParameterAuthorizer.java | 96 ++++++++-----
.../parameter/ParameterAuthorizerTest.java | 149 +++++++++++++++++++++
.../parameter/ParametersInterceptorTest.java | 45 +++++++
3 files changed, 260 insertions(+), 30 deletions(-)
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
index 423b885b0..481556eaf 100644
---
a/core/src/main/java/org/apache/struts2/interceptor/parameter/StrutsParameterAuthorizer.java
+++
b/core/src/main/java/org/apache/struts2/interceptor/parameter/StrutsParameterAuthorizer.java
@@ -30,15 +30,18 @@ import org.apache.struts2.util.ProxyService;
import java.beans.BeanInfo;
import java.beans.IntrospectionException;
+import java.beans.MethodDescriptor;
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.Objects;
import java.util.Optional;
import static java.lang.String.format;
+import static java.util.Comparator.comparingInt;
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;
@@ -162,10 +165,9 @@ public class StrutsParameterAuthorizer implements
ParameterAuthorizer {
* A property declared on neither is allowed: typically it is bound by a
custom OGNL property accessor on
* the model, such as a Map-backed model. That fallback guarantees less
than it may appear to - only that
* the name reaches no member {@link #declaresProperty} can see. OGNL
walks the whole stack, so such a name
- * can still land on the action wherever the action absorbs it by a route
introspection here does not model:
- * being a {@code Map} itself, or declaring a setter that OGNL matches on
name and arity while
- * {@link java.beans.Introspector} does not, a fluent one for instance -
see WW-5709. Neither case is more
- * permissive than the blanket exemption this scoping replaces.
+ * can still land on the action wherever the action absorbs it by a route
introspection here does not model,
+ * being a {@code Map} itself for instance. That is no more permissive
than the blanket exemption this
+ * scoping replaces.
* <p>
* {@code class} is the exception to that fallback: it is invisible to
introspection here rather than absent,
* so it is rejected instead of taking the fallback, which keeps a
ModelDriven action from handing OGNL a
@@ -203,13 +205,53 @@ public class StrutsParameterAuthorizer implements
ParameterAuthorizer {
* scoping exists to prevent. Inherited public fields count for the same
reason, that OGNL can set them.
*/
protected boolean declaresProperty(Object target, String property, long
paramDepth) {
+ return findBindableAccessor(target, property, paramDepth).isPresent()
+ || declaresBindablePublicField(target, property, paramDepth);
+ }
+
+ /**
+ * The method OGNL would go through to bind {@code property} on {@code
target} at this depth: the setter for a
+ * depth-0 parameter, the getter for a nested one.
+ * <p>
+ * The setter is matched the way OGNL matches it - a public instance
method named {@code set} plus the
+ * capitalised property name, taking one argument - and not through {@link
PropertyDescriptor#getWriteMethod()},
+ * which {@link java.beans.Introspector} only fills in for a {@code void}
setter. A fluent setter returning
+ * {@code this} is just as bindable to OGNL, so it has to be just as
visible here, both to carry a
+ * {@link StrutsParameter} annotation and to count as declared on a {@link
ModelDriven} action.
+ * <p>
+ * Where several setters qualify, the one declared furthest down the
hierarchy wins - an override is what OGNL
+ * invokes and what the developer annotated, while the erased setter of a
generic superclass is listed
+ * alongside it and carries no annotation - and among overloads declared
at that level an annotated one, since
+ * annotating any overload declares the property request surface.
+ */
+ protected Optional<Method> findBindableAccessor(Object target, String
property, long paramDepth) {
BeanInfo beanInfo = getBeanInfo(target);
- if (beanInfo != null &&
Arrays.stream(beanInfo.getPropertyDescriptors())
+ if (beanInfo == null) {
+ return Optional.empty();
+ }
+ if (paramDepth == 0) {
+ String setterName = "set" +
Character.toUpperCase(property.charAt(0)) + property.substring(1);
+ return Arrays.stream(beanInfo.getMethodDescriptors())
+ .map(MethodDescriptor::getMethod)
+ .filter(method -> method.getName().equals(setterName)
+ && method.getParameterCount() == 1
+ && !Modifier.isStatic(method.getModifiers()))
+ .max(comparingInt((Method method) ->
inheritanceDepth(method.getDeclaringClass()))
+ .thenComparing(method ->
getParameterAnnotation(method) != null));
+ }
+ return Arrays.stream(beanInfo.getPropertyDescriptors())
.filter(desc -> desc.getName().equals(property))
- .anyMatch(desc -> (paramDepth == 0 ? desc.getWriteMethod() :
desc.getReadMethod()) != null)) {
- return true;
+ .map(PropertyDescriptor::getReadMethod)
+ .filter(Objects::nonNull)
+ .findFirst();
+ }
+
+ private static int inheritanceDepth(Class<?> type) {
+ int depth = 0;
+ for (Class<?> ancestor = type.getSuperclass(); ancestor != null;
ancestor = ancestor.getSuperclass()) {
+ depth++;
}
- return declaresBindablePublicField(target, property, paramDepth);
+ return depth;
}
/**
@@ -231,35 +273,29 @@ public class StrutsParameterAuthorizer implements
ParameterAuthorizer {
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)) {
+ Optional<Method> accessor = findBindableAccessor(target, rootProperty,
paramDepth);
+ if (accessor.isPresent() && hasValidAnnotatedMethod(target,
accessor.get(), paramDepth)) {
return true;
}
-
return hasValidAnnotatedField(target, rootProperty, paramDepth);
}
+ /**
+ * @deprecated a {@link PropertyDescriptor} cannot describe every setter
OGNL binds through; use
+ * {@link #findBindableAccessor} with {@link #hasValidAnnotatedMethod}
instead
+ */
+ @Deprecated(since = "7.4.0", forRemoval = true)
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) {
+ return relevantMethod != null && hasValidAnnotatedMethod(target,
relevantMethod, paramDepth);
+ }
+
+ protected boolean hasValidAnnotatedMethod(Object target, Method method,
long paramDepth) {
+ if (getPermittedInjectionDepth(method) < 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());
+ method.getName(),
+ method.getDeclaringClass().getName());
if (devMode) {
notifyDeveloperOfError(LOG, target, logMessage);
} else {
@@ -267,8 +303,8 @@ public class StrutsParameterAuthorizer implements
ParameterAuthorizer {
}
return false;
}
- LOG.debug("Success: Matching annotated method [{}] found for property
[{}] of depth [{}] on target [{}]",
- relevantMethod.getName(), propDesc.getName(), paramDepth,
targetClass.getSimpleName());
+ LOG.debug("Success: Matching annotated method [{}] of depth [{}] found
on target [{}]",
+ method.getName(), paramDepth,
ultimateClass(target).getSimpleName());
return true;
}
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
index 1157ed81b..b412da4d7 100644
---
a/core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizerTest.java
+++
b/core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizerTest.java
@@ -219,6 +219,68 @@ public class ParameterAuthorizerTest {
assertThat(authorizer.isAuthorized("constant", action.getModel(),
action)).isFalse();
}
+ // --- Fluent setters (WW-5709) ---
+
+ @Test
+ public void annotatedFluentSetter_authorized() {
+ // OGNL binds through any public one-argument setX method, whatever it
returns; java.beans only
+ // sees void ones. The annotation has to count on the method OGNL will
actually call.
+ var action = new FluentAction();
+ assertThat(authorizer.isAuthorized("name", action, action)).isTrue();
+ }
+
+ @Test
+ public void unannotatedFluentSetter_rejected() {
+ var action = new FluentAction();
+ assertThat(authorizer.isAuthorized("role", action, action)).isFalse();
+ }
+
+ @Test
+ public void modelDriven_unannotatedFluentSetterOnAction_rejected() {
+ // Invisible to java.beans, the fluent setter used to look declared on
neither the model nor the
+ // action and took the custom-accessor fallback, which is the one gap
WW-5698 left open.
+ var action = new ModelActionWithFluentSetter();
+ assertThat(authorizer.isAuthorized("actionSecret", action.getModel(),
action)).isFalse();
+ }
+
+ @Test
+ public void modelDriven_annotatedFluentSetterOnAction_authorized() {
+ var action = new ModelActionWithFluentSetter();
+ assertThat(authorizer.isAuthorized("actionAllowed", action.getModel(),
action)).isTrue();
+ }
+
+ @Test
+ public void
modelDriven_fluentSetterOnModelShadowingUnannotatedActionSetter_authorized() {
+ // The model absorbs the parameter through its fluent setter, so the
action's unannotated
+ // namesake is never reached.
+ var action = new ModelActionWithFluentModel();
+ assertThat(authorizer.isAuthorized("shared", action.getModel(),
action)).isTrue();
+ }
+
+ @Test
+ public void
modelDriven_staticSetterNamesakeOfUnannotatedActionProperty_rejected() {
+ // OGNL never invokes static methods on a request path, so a static
setX on the model does not
+ // absorb the parameter and must not exempt the action's namesake.
+ var action = new ModelActionWithStaticSetterNamesake();
+ assertThat(authorizer.isAuthorized("constant", action.getModel(),
action)).isFalse();
+ }
+
+ @Test
+ public void annotatedSetterOverridingGenericBaseSetter_authorized() {
+ // The base class's erased setModel(Object) is listed alongside the
override. The override is the
+ // method OGNL invokes and the one the developer annotated, so it must
be the one judged.
+ var action = new GenericOverrideAction();
+ assertThat(authorizer.isAuthorized("model", action, action)).isTrue();
+ }
+
+ @Test
+ public void annotatedSetterBesideUnannotatedOverload_authorized() {
+ // Overloads tie on inheritance depth and the Introspector lists them
in type-name order, so
+ // setAge(int) comes before setAge(String). Annotating either overload
declares the property.
+ var action = new OverloadedSetterAction();
+ assertThat(authorizer.isAuthorized("age", action, action)).isTrue();
+ }
+
@Test
public void modelDriven_classProperty_rejected() {
// OgnlUtil introspects with Object as the stop class, so "class"
shows up on no descriptor list
@@ -467,6 +529,93 @@ public class ParameterAuthorizerTest {
public String getConstant() { return constant; }
}
+ public static class FluentAction {
+ private String name;
+ private String role;
+
+ @StrutsParameter
+ public FluentAction setName(String name) { this.name = name; return
this; }
+ public String getName() { return name; }
+
+ // NO @StrutsParameter
+ public FluentAction setRole(String role) { this.role = role; return
this; }
+ public String getRole() { return role; }
+ }
+
+ public static class ModelActionWithFluentSetter implements
ModelDriven<Pojo> {
+ private final Pojo model = new Pojo();
+ private String actionSecret;
+ private String actionAllowed;
+
+ @Override
+ public Pojo getModel() { return model; }
+
+ // NO @StrutsParameter
+ public ModelActionWithFluentSetter setActionSecret(String
actionSecret) { this.actionSecret = actionSecret; return this; }
+ public String getActionSecret() { return actionSecret; }
+
+ @StrutsParameter
+ public ModelActionWithFluentSetter setActionAllowed(String
actionAllowed) { this.actionAllowed = actionAllowed; return this; }
+ public String getActionAllowed() { return actionAllowed; }
+ }
+
+ public static class FluentModel {
+ private String shared;
+ public FluentModel setShared(String shared) { this.shared = shared;
return this; }
+ public String getShared() { return shared; }
+ }
+
+ public static class ModelActionWithFluentModel implements
ModelDriven<FluentModel> {
+ private final FluentModel model = new FluentModel();
+ private String shared;
+
+ @Override
+ public FluentModel getModel() { return model; }
+
+ // NO @StrutsParameter
+ public void setShared(String shared) { this.shared = shared; }
+ public String getShared() { return shared; }
+ }
+
+ public static class ModelWithStaticSetter {
+ public static void setConstant(String ignored) { }
+ }
+
+ public static class ModelActionWithStaticSetterNamesake implements
ModelDriven<ModelWithStaticSetter> {
+ private final ModelWithStaticSetter model = new
ModelWithStaticSetter();
+ private String constant;
+
+ @Override
+ public ModelWithStaticSetter getModel() { return model; }
+
+ // NO @StrutsParameter
+ public void setConstant(String constant) { this.constant = constant; }
+ public String getConstant() { return constant; }
+ }
+
+ public static class GenericBase<T> {
+ public void setModel(T model) { }
+ }
+
+ public static class GenericOverrideAction extends GenericBase<String> {
+ private String model;
+
+ @StrutsParameter
+ @Override
+ public void setModel(String model) { this.model = model; }
+ public String getModel() { return model; }
+ }
+
+ public static class OverloadedSetterAction {
+ private String age;
+
+ @StrutsParameter
+ public void setAge(String age) { this.age = age; }
+ // NO @StrutsParameter - convenience overload
+ public void setAge(int age) { this.age = String.valueOf(age); }
+ public String getAge() { return age; }
+ }
+
public static class Pojo {
private String name;
private String shared;
diff --git
a/core/src/test/java/org/apache/struts2/interceptor/parameter/ParametersInterceptorTest.java
b/core/src/test/java/org/apache/struts2/interceptor/parameter/ParametersInterceptorTest.java
index 2b1b4386c..c5c7fffc0 100644
---
a/core/src/test/java/org/apache/struts2/interceptor/parameter/ParametersInterceptorTest.java
+++
b/core/src/test/java/org/apache/struts2/interceptor/parameter/ParametersInterceptorTest.java
@@ -22,7 +22,9 @@ import org.apache.struts2.action.Action;
import org.apache.struts2.ActionContext;
import org.apache.struts2.ActionProxy;
import org.apache.struts2.ActionSupport;
+import org.apache.struts2.ModelDriven;
import org.apache.struts2.ModelDrivenAction;
+import org.apache.struts2.StrutsConstants;
import org.apache.struts2.SimpleAction;
import org.apache.struts2.TestBean;
import org.apache.struts2.text.TextProvider;
@@ -255,6 +257,32 @@ public class ParametersInterceptorTest extends
XWorkTestCase {
assertEquals(fooVal, action.getFoo());
}
+ /**
+ * WW-5709: OGNL binds through a public one-argument setX method whatever
it returns, so a fluent setter on a
+ * ModelDriven action is as much the action's own member as a void one and
needs the same annotation. The model
+ * property alongside it proves the parameters were applied at all.
+ */
+ public void testModelDrivenFluentSetterOnActionRequiresAnnotation() throws
Exception {
+
loadButSet(Map.of(StrutsConstants.STRUTS_PARAMETERS_REQUIRE_ANNOTATIONS,
"true"));
+ ParametersInterceptor pi = createParametersInterceptor();
+
+ FluentModelDrivenAction action = new FluentModelDrivenAction();
+ ValueStack stack =
container.getInstance(ValueStackFactory.class).createValueStack();
+ stack.push(action);
+ stack.push(action.getModel());
+
ActionContext.of().withContainer(container).withValueStack(stack).bind();
+
+ Map<String, Object> params = new HashMap<>();
+ params.put("secret", "leaked through the fluent setter");
+ params.put("allowed", "bound through the annotated fluent setter");
+ params.put("name", "bound on the model");
+ pi.applyParameters(action, stack,
HttpParameters.create(params).build());
+
+ assertEquals("bound on the model", action.getModel().getName());
+ assertEquals("bound through the annotated fluent setter",
action.getAllowed());
+ assertNull(action.getSecret());
+ }
+
public void testParametersDoesNotAffectSession() throws Exception {
Map<String, Object> params = new HashMap<>();
params.put("blah", "This is blah");
@@ -996,6 +1024,23 @@ public class ParametersInterceptorTest extends
XWorkTestCase {
*/
+ public static class FluentModelDrivenAction implements
ModelDriven<TestBean> {
+ private final TestBean model = new TestBean();
+ private String secret;
+ private String allowed;
+
+ @Override
+ public TestBean getModel() { return model; }
+
+ // NO @StrutsParameter
+ public FluentModelDrivenAction setSecret(String secret) { this.secret
= secret; return this; }
+ public String getSecret() { return secret; }
+
+ @StrutsParameter
+ public FluentModelDrivenAction setAllowed(String allowed) {
this.allowed = allowed; return this; }
+ public String getAllowed() { return allowed; }
+ }
+
private class NoParametersAction implements Action, NoParameters {
public String execute() throws Exception {