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 9aa77f721 WW-5759 Resolve @StrutsParameter accessors the way OGNL does
(#1992)
9aa77f721 is described below
commit 9aa77f7219db00c5ae1c7b728e0bca93d16e2053
Author: Lukasz Lenart <[email protected]>
AuthorDate: Tue Oct 6 15:15:06 2026 +0200
WW-5759 Resolve @StrutsParameter accessors the way OGNL does (#1992)
* WW-5759 fix(core): resolve @StrutsParameter accessors the way OGNL does
OGNL 3.4.14 tries setuRange before setURange for uRange, and reaches
getURL() for both
uRL and URL, while StrutsParameterAuthorizer looked only for set/get plus
the capitalised
name and matched nested parameters against the java.beans property name.
findBindableAccessor
now tries OGNL's accessor base names in OGNL's order, and the root property
is normalised
with Introspector.decapitalize so the candidates match OGNL's for the name
as written.
Bumps OGNL from 3.4.13 to 3.4.14.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* WW-5759 test(core): pin that case variants of an accessor name bind
nothing on a ModelDriven action
They resolve to no accessor on the model or the action and take the
"declared on neither"
route, which is only safe while OGNL does not bind them either.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* WW-5759 chore(core): address Sonar findings on the accessor resolution
change
Make the non-null target contract of ultimateClass explicit, drop throws
clauses the new
tests do not need and record the values the probe setters receive.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../parameter/StrutsParameterAuthorizer.java | 68 ++++++----
.../parameter/ParametersInterceptorTest.java | 142 +++++++++++++++++++++
pom.xml | 2 +-
3 files changed, 187 insertions(+), 25 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 481556eaf..a0feb6cd2 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,6 +30,7 @@ import org.apache.struts2.util.ProxyService;
import java.beans.BeanInfo;
import java.beans.IntrospectionException;
+import java.beans.Introspector;
import java.beans.MethodDescriptor;
import java.beans.PropertyDescriptor;
import java.lang.reflect.AnnotatedElement;
@@ -37,6 +38,7 @@ import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;
+import java.util.List;
import java.util.Objects;
import java.util.Optional;
@@ -132,7 +134,7 @@ public class StrutsParameterAuthorizer implements
ParameterAuthorizer {
parameterName);
return false;
}
- String normalisedRootProperty =
Character.toLowerCase(rootProperty.charAt(0)) + rootProperty.substring(1);
+ String normalisedRootProperty =
Introspector.decapitalize(rootProperty);
// Transition mode: depth-0 (non-nested) parameters are exempt.
Checked before the ModelDriven
// exemption so that it also covers a ModelDriven action's own
members, which would otherwise
@@ -213,37 +215,54 @@ public class StrutsParameterAuthorizer implements
ParameterAuthorizer {
* 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.
+ * The accessor is matched the way OGNL matches it - a public instance
method named {@code set}, or {@code is}
+ * then {@code get}, plus each of {@link #accessorBaseNames}, taking one
argument or none, the first name with a
+ * match winning - and not through a {@link PropertyDescriptor}. {@link
java.beans.Introspector} only fills in
+ * the write method for a {@code void} setter, and names the property of
{@code getURL} {@code URL} where OGNL
+ * reaches it as {@code uRL} too. 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.
+ * Where several methods of the winning name 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) {
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));
+ List<String> prefixes = paramDepth == 0 ? List.of("set") :
List.of("is", "get");
+ int parameterCount = paramDepth == 0 ? 1 : 0;
+ for (String baseName : accessorBaseNames(property)) {
+ for (String prefix : prefixes) {
+ String accessorName = prefix + baseName;
+ Optional<Method> accessor =
Arrays.stream(beanInfo.getMethodDescriptors())
+ .map(MethodDescriptor::getMethod)
+ .filter(method -> method.getName().equals(accessorName)
+ && method.getParameterCount() == parameterCount
+ && !Modifier.isStatic(method.getModifiers()))
+ .max(comparingInt((Method method) ->
inheritanceDepth(method.getDeclaringClass()))
+ .thenComparing(method ->
getParameterAnnotation(method) != null));
+ if (accessor.isPresent()) {
+ return accessor;
+ }
+ }
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * The accessor base names OGNL tries for {@code property}, in its order:
for a name like {@code uRange} the
+ * JavaBeans {@code uRange} before the capitalised {@code URange},
otherwise the capitalised name alone.
+ */
+ private static List<String> accessorBaseNames(String property) {
+ String capitalised = Character.toUpperCase(property.charAt(0)) +
property.substring(1);
+ if (property.length() > 1 && Character.isLowerCase(property.charAt(0))
&& Character.isUpperCase(property.charAt(1))) {
+ return List.of(property, capitalised);
}
- return Arrays.stream(beanInfo.getPropertyDescriptors())
- .filter(desc -> desc.getName().equals(property))
- .map(PropertyDescriptor::getReadMethod)
- .filter(Objects::nonNull)
- .findFirst();
+ return List.of(capitalised);
}
private static int inheritanceDepth(Class<?> type) {
@@ -353,6 +372,7 @@ public class StrutsParameterAuthorizer implements
ParameterAuthorizer {
}
protected Class<?> ultimateClass(Object target) {
+ Objects.requireNonNull(target, "target");
if (proxyService.isProxy(target)) {
return proxyService.ultimateTargetClass(target);
}
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 9b0d52b6b..d3c15279e 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
@@ -320,6 +320,104 @@ public class ParametersInterceptorTest extends
XWorkTestCase {
}
}
+ /**
+ * OGNL resolves {@code uRange} to the JavaBeans setter {@code setuRange}
before {@code setURange}, so that is the
+ * method which has to carry the annotation. The model property alongside
proves the parameters were applied.
+ */
+ public void
testModelDrivenJavaBeansStyleSetterOnActionRequiresAnnotation() {
+
loadButSet(Map.of(StrutsConstants.STRUTS_PARAMETERS_REQUIRE_ANNOTATIONS,
"true"));
+ ParametersInterceptor pi = createParametersInterceptor();
+
+ JavaBeansNamedModelDrivenAction action = new
JavaBeansNamedModelDrivenAction();
+ 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("uRange", "leaked through the unannotated setter");
+ params.put("xCoord", "bound through the annotated 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 setter", action.getxCoord());
+ assertNull(action.getuRange());
+ }
+
+ /**
+ * A nested parameter goes through the getter OGNL resolves, {@code
getURL} for {@code uRL}, which
+ * introspection names property {@code URL}.
+ */
+ public void testModelDrivenUppercaseGetterOnActionRequiresAnnotation() {
+
loadButSet(Map.of(StrutsConstants.STRUTS_PARAMETERS_REQUIRE_ANNOTATIONS,
"true"));
+ ParametersInterceptor pi = createParametersInterceptor();
+
+ JavaBeansNamedModelDrivenAction action = new
JavaBeansNamedModelDrivenAction();
+ 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("uRL.host", "leaked through the unannotated getter");
+ params.put("URL.path", "leaked through the unannotated getter");
+ params.put("name", "bound on the model");
+ pi.applyParameters(action, stack,
HttpParameters.create(params).build());
+
+ assertEquals("bound on the model", action.getModel().getName());
+ assertTrue(action.getURL().isEmpty());
+ }
+
+ /**
+ * The annotation counts only on the setter OGNL actually invokes for the
parameter name as written.
+ */
+ public void testAnnotationOnlyCountsOnTheSetterOgnlInvokes() {
+
loadButSet(Map.of(StrutsConstants.STRUTS_PARAMETERS_REQUIRE_ANNOTATIONS,
"true"));
+ ParametersInterceptor pi = createParametersInterceptor();
+
+ JavaBeansNamedAction action = new JavaBeansNamedAction();
+ ValueStack stack =
container.getInstance(ValueStackFactory.class).createValueStack();
+ stack.push(action);
+
ActionContext.of().withContainer(container).withValueStack(stack).bind();
+
+ Map<String, Object> params = new HashMap<>();
+ params.put("xCoord", "bound through the annotated setter");
+ params.put("uRange", "leaked through the unannotated setuRange");
+ params.put("VRange", "leaked through the unannotated setVRange");
+ pi.applyParameters(action, stack,
HttpParameters.create(params).build());
+
+ assertEquals("bound through the annotated setter", action.getxCoord());
+ assertEquals(List.of(), action.getInvoked());
+ }
+
+ /**
+ * Names differing from an accessor's property only in case resolve to no
accessor on either object, so they
+ * take the "declared on neither" route - which is safe only as long as
OGNL does not bind them either.
+ */
+ public void testModelDrivenCaseVariantsBindNothingOnAction() {
+
loadButSet(Map.of(StrutsConstants.STRUTS_PARAMETERS_REQUIRE_ANNOTATIONS,
"true"));
+ ParametersInterceptor pi = createParametersInterceptor();
+
+ JavaBeansNamedModelDrivenAction action = new
JavaBeansNamedModelDrivenAction();
+ 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("urange", "not bound");
+ params.put("URANGE", "not bound");
+ params.put("url.host", "not bound");
+ params.put("Url.path", "not bound");
+ params.put("name", "bound on the model");
+ pi.applyParameters(action, stack,
HttpParameters.create(params).build());
+
+ assertEquals("bound on the model", action.getModel().getName());
+ assertNull(action.getuRange());
+ assertTrue(action.getURL().isEmpty());
+ }
+
public void testParametersDoesNotAffectSession() throws Exception {
Map<String, Object> params = new HashMap<>();
params.put("blah", "This is blah");
@@ -1110,6 +1208,50 @@ public class ParametersInterceptorTest extends
XWorkTestCase {
public Address getAddress() { return address; }
}
+ public static class JavaBeansNamedModelDrivenAction implements
ModelDriven<TestBean> {
+ private final TestBean model = new TestBean();
+ private final Map<String, String> url = new HashMap<>();
+ private String uRange;
+ private String xCoord;
+
+ @Override
+ public TestBean getModel() { return model; }
+
+ // NO @StrutsParameter
+ public void setuRange(String uRange) { this.uRange = uRange; }
+ public String getuRange() { return uRange; }
+
+ @StrutsParameter
+ public void setxCoord(String xCoord) { this.xCoord = xCoord; }
+ public String getxCoord() { return xCoord; }
+
+ // NO @StrutsParameter
+ public Map<String, String> getURL() { return url; }
+ }
+
+ public static class JavaBeansNamedAction {
+ private final List<String> invoked = new ArrayList<>();
+ private String xCoord;
+
+ @StrutsParameter
+ public void setxCoord(String xCoord) { this.xCoord = xCoord; }
+ public String getxCoord() { return xCoord; }
+
+ // NO @StrutsParameter - OGNL prefers it to the annotated setURange
for "uRange"
+ public void setuRange(String uRange) { invoked.add("setuRange=" +
uRange); }
+
+ @StrutsParameter
+ public void setURange(String uRange) { invoked.add("setURange=" +
uRange); }
+
+ @StrutsParameter
+ public void setvRange(String vRange) { invoked.add("setvRange=" +
vRange); }
+
+ // NO @StrutsParameter - the only setter OGNL considers for "VRange"
+ public void setVRange(String vRange) { invoked.add("setVRange=" +
vRange); }
+
+ public List<String> getInvoked() { return invoked; }
+ }
+
public static class Address {
private String city;
public String getCity() { return city; }
diff --git a/pom.xml b/pom.xml
index 89594f359..b24d1c78d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -126,7 +126,7 @@
<jaxb-impl.version>4.0.9</jaxb-impl.version>
<log4j2.version>2.26.1</log4j2.version>
<mockito.version>5.23.0</mockito.version>
- <ognl.version>3.4.13</ognl.version>
+ <ognl.version>3.4.14</ognl.version>
<spring.version>6.2.19</spring.version>
<struts-annotations.version>2.0</struts-annotations.version>
<velocity-tools.version>3.1</velocity-tools.version>