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
index d064fbf68e..6c5848bd1d 100644
--- a/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizer.java
+++ b/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizer.java
@@ -45,4 +45,24 @@ public interface ParameterAuthorizer {
* @return {@code true} if the parameter is authorized for injection, {@code false} otherwise
*/
boolean isAuthorized(String parameterName, Object target, Object action);
+
+ /**
+ * Resolves the target object whose annotations should be checked for authorization.
+ * For {@link org.apache.struts2.ModelDriven} actions, the default implementation returns the action itself;
+ * the production implementation ({@link StrutsParameterAuthorizer}) overrides this to return the model from
+ * the value stack.
+ *
+ *
Callers that need both authorization checks AND the resolved target (e.g. for downstream OGNL allowlisting)
+ * should call this once and reuse the result.
+ *
+ *
This is a {@code default} method to preserve the interface as a functional interface (SAM) for
+ * lambda-based test stubs.
+ *
+ * @param action the action instance
+ * @return the resolved target — either the action or its model
+ * @since 7.2.0
+ */
+ default Object resolveTarget(Object action) {
+ return 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 6173a85c65..5491b585f2 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
@@ -23,7 +23,6 @@
import org.apache.logging.log4j.Logger;
import org.apache.struts2.ActionContext;
import org.apache.struts2.ActionInvocation;
-import org.apache.struts2.ModelDriven;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.action.NoParameters;
import org.apache.struts2.action.ParameterNameAware;
@@ -369,14 +368,7 @@ protected boolean isParameterAnnotatedAndAllowlist(String name, Object action) {
return true;
}
- // 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;
- }
- }
+ Object target = parameterAuthorizer.resolveTarget(action);
// Delegate authorization check to shared ParameterAuthorizer (no OGNL side effects)
if (!parameterAuthorizer.isAuthorized(name, target, action)) {
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 82e47717e3..03cc22c0e5 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
@@ -21,6 +21,7 @@
import org.apache.commons.lang3.BooleanUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import org.apache.struts2.ActionContext;
import org.apache.struts2.ModelDriven;
import org.apache.struts2.StrutsConstants;
import org.apache.struts2.inject.Inject;
@@ -91,6 +92,17 @@ public void setRequireAnnotationsTransitionMode(String transitionMode) {
this.requireAnnotationsTransitionMode = BooleanUtils.toBoolean(transitionMode);
}
+ @Override
+ public Object resolveTarget(Object action) {
+ if (action instanceof ModelDriven>) {
+ Object stackTop = ActionContext.getContext().getValueStack().peek();
+ if (!stackTop.equals(action)) {
+ return stackTop;
+ }
+ }
+ return action;
+ }
+
@Override
public boolean isAuthorized(String parameterName, Object target, Object action) {
if (parameterName == null || parameterName.isEmpty()) {
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 f7c16fbb92..43eb57bcc2 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
@@ -18,13 +18,16 @@
*/
package org.apache.struts2.interceptor.parameter;
+import org.apache.struts2.ActionContext;
import org.apache.struts2.ModelDriven;
+import org.apache.struts2.StubValueStack;
import org.apache.struts2.ognl.DefaultOgnlBeanInfoCacheFactory;
import org.apache.struts2.ognl.DefaultOgnlExpressionCacheFactory;
import org.apache.struts2.ognl.OgnlUtil;
import org.apache.struts2.ognl.StrutsOgnlGuard;
import org.apache.struts2.ognl.StrutsProxyCacheFactory;
import org.apache.struts2.util.StrutsProxyService;
+import org.junit.After;
import org.junit.Before;
import org.junit.Test;
@@ -56,6 +59,11 @@ public void setUp() {
authorizer.setProxyService(proxyService);
}
+ @After
+ public void tearDown() {
+ ActionContext.clear();
+ }
+
// --- requireAnnotations=false (backward compat) ---
@Test
@@ -182,6 +190,37 @@ public void emptyParameterName_rejectedEvenWhenAnnotationsNotRequired() {
assertThat(authorizer.isAuthorized(null, action, action)).isFalse();
}
+ // --- resolveTarget ---
+
+ @Test
+ public void resolveTarget_nonModelDriven_returnsAction() {
+ var action = new SecureAction();
+ assertThat(authorizer.resolveTarget(action)).isSameAs(action);
+ }
+
+ @Test
+ public void resolveTarget_modelDriven_returnsModelFromValueStack() {
+ var action = new ModelAction();
+ var model = action.getModel();
+ var valueStack = new StubValueStack();
+ valueStack.push(model);
+ ActionContext.of().withValueStack(valueStack).bind();
+
+ assertThat(authorizer.resolveTarget(action)).isSameAs(model);
+ }
+
+ @Test
+ public void resolveTarget_modelDriven_stackTopEqualsAction_returnsAction() {
+ // Edge case: ModelDriven action where stack top equals the action itself.
+ // No exemption applies — target stays as action.
+ var action = new ModelAction();
+ var valueStack = new StubValueStack();
+ valueStack.push(action);
+ ActionContext.of().withValueStack(valueStack).bind();
+
+ assertThat(authorizer.resolveTarget(action)).isSameAs(action);
+ }
+
// --- Inner test classes ---
public static class SecureAction {
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 a0279f2f09..d0acfd4ce0 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
@@ -215,7 +215,14 @@ private void filterUnauthorizedKeysRecursive(Map json, String prefix, Object tar
Iterator it = json.entrySet().iterator();
while (it.hasNext()) {
Map.Entry entry = it.next();
- String key = (String) entry.getKey();
+ if (!(entry.getKey() instanceof String key)) {
+ // Defensive: a custom JSONReader could produce non-String keys. Skip — we cannot
+ // construct a parameter path for authorization, and JSONPopulator wouldn't bind
+ // these to bean properties anyway.
+ LOG.debug("Skipping JSON entry with non-String key [{}] of type [{}] under prefix [{}]",
+ entry.getKey(), entry.getKey() == null ? "null" : entry.getKey().getClass().getName(), prefix);
+ continue;
+ }
String fullPath = prefix.isEmpty() ? key : prefix + "." + key;
if (!parameterAuthorizer.isAuthorized(fullPath, target, action)) {
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 2203f6f290..ece2f1272d 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
@@ -583,6 +583,32 @@ public void testParameterAuthorizerRejectsUnauthorizedKeys() throws Exception {
assertNull(action.getBar());
}
+ public void testNonStringKeysAreSkippedByAuthorizationFilter() throws Exception {
+ // Simulate a custom JSON reader producing a Map with a non-String key.
+ // The authorizer should skip the entry rather than throw ClassCastException.
+ JSONInterceptor interceptor = new JSONInterceptor();
+ JSONUtil jsonUtil = new JSONUtil();
+ jsonUtil.setReader(new StrutsJSONReader());
+ jsonUtil.setWriter(new StrutsJSONWriter());
+ interceptor.setJsonUtil(jsonUtil);
+ interceptor.setParameterAuthorizer((parameterName, target, action) -> true);
+
+ java.util.Map