Repository: apache/struts
Pull request #1237: WW-5534 Allow @StrutsParameter recognition and OGNL allowlist for Spring proxies
Author: login: kusalk
Created: 2025-02-27T17:43:10Z | Merged: 2025-03-06T01:24:07Z

WW-5534
--
This change fixes `@StrutsParameter` detection for Spring proxied Actions. It additionally adds optional support for resolving Spring proxies when enforcing the OGNL allowlist, alongside the existing support for resolving Hibernate proxies.


Diff:
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 92449415e3..4cd1c3faa9 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
@@ -33,12 +33,14 @@
 import org.apache.struts2.dispatcher.Parameter;
 import org.apache.struts2.inject.Inject;
 import org.apache.struts2.interceptor.MethodFilterInterceptor;
+import org.apache.struts2.ognl.OgnlUtil;
 import org.apache.struts2.ognl.ThreadAllowlist;
 import org.apache.struts2.security.AcceptedPatternsChecker;
 import org.apache.struts2.security.DefaultAcceptedPatternsChecker;
 import org.apache.struts2.security.ExcludedPatternsChecker;
 import org.apache.struts2.util.ClearableValueStack;
 import org.apache.struts2.util.MemberAccessValueStack;
+import org.apache.struts2.util.ProxyUtil;
 import org.apache.struts2.util.TextParseUtil;
 import org.apache.struts2.util.ValueStack;
 import org.apache.struts2.util.ValueStackFactory;
@@ -46,7 +48,6 @@
 
 import java.beans.BeanInfo;
 import java.beans.IntrospectionException;
-import java.beans.Introspector;
 import java.beans.PropertyDescriptor;
 import java.lang.reflect.AnnotatedElement;
 import java.lang.reflect.Field;
@@ -93,6 +94,7 @@ public class ParametersInterceptor extends MethodFilterInterceptor {
     protected boolean requireAnnotationsTransitionMode = false;
 
     private ValueStackFactory valueStackFactory;
+    private OgnlUtil ognlUtil;
     protected ThreadAllowlist threadAllowlist;
     private ExcludedPatternsChecker excludedPatterns;
     private AcceptedPatternsChecker acceptedPatterns;
@@ -104,6 +106,11 @@ public void setValueStackFactory(ValueStackFactory valueStackFactory) {
         this.valueStackFactory = valueStackFactory;
     }
 
+    @Inject
+    public void setOgnlUtil(OgnlUtil ognlUtil) {
+        this.ognlUtil = ognlUtil;
+    }
+
     @Inject
     public void setThreadAllowlist(ThreadAllowlist threadAllowlist) {
         this.threadAllowlist = threadAllowlist;
@@ -395,6 +402,7 @@ protected boolean hasValidAnnotatedMember(String rootProperty, Object action, lo
     }
 
     protected boolean hasValidAnnotatedPropertyDescriptor(Object action, PropertyDescriptor propDesc, long paramDepth) {
+        Class<?> actionClass = ultimateClass(action);
         Method relevantMethod = paramDepth == 0 ? propDesc.getWriteMethod() : propDesc.getReadMethod();
         if (relevantMethod == null) {
             return false;
@@ -412,7 +420,7 @@ protected boolean hasValidAnnotatedPropertyDescriptor(Object action, PropertyDes
             return false;
         }
         LOG.debug("Success: Matching annotated method [{}] found for property [{}] of depth [{}] on Action [{}]",
-                relevantMethod.getName(), propDesc.getName(), paramDepth, action.getClass().getSimpleName());
+                relevantMethod.getName(), propDesc.getName(), paramDepth, actionClass.getSimpleName());
         if (paramDepth >= 1) {
             allowlistClass(propDesc.getPropertyType());
         }
@@ -451,24 +459,25 @@ protected void allowlistClass(Class<?> clazz) {
     }
 
     protected boolean hasValidAnnotatedField(Object action, String fieldName, long paramDepth) {
+        Class<?> actionClass = ultimateClass(action);
         LOG.debug("No matching annotated method found for property [{}] of depth [{}] on Action [{}], now also checking for public field",
-                fieldName, paramDepth, action.getClass().getSimpleName());
+                fieldName, paramDepth, actionClass.getSimpleName());
         Field field;
         try {
-            field = action.getClass().getDeclaredField(fieldName);
+            field = actionClass.getDeclaredField(fieldName);
         } catch (NoSuchFieldException e) {
-            LOG.debug("Matching field for property [{}] not found on Action [{}]", fieldName, action.getClass().getSimpleName());
+            LOG.debug("Matching field for property [{}] not found on Action [{}]", fieldName, actionClass.getSimpleName());
             return false;
         }
         if (!Modifier.isPublic(field.getModifiers())) {
-            LOG.debug("Matching field [{}] is not public on Action [{}]", field.getName(), action.getClass().getSimpleName());
+            LOG.debug("Matching field [{}] is not public on Action [{}]", field.getName(), actionClass.getSimpleName());
             return false;
         }
         if (getPermittedInjectionDepth(field) < paramDepth) {
             String logMessage = format(
                     "Parameter injection for field [%s] on Action [%s] rejected. Ensure it is annotated with @StrutsParameter with an appropriate 'depth'.",
                     field.getName(),
-                    action.getClass().getName());
+                    actionClass.getName());
             if (devMode) {
                 notifyDeveloperOfError(LOG, action, logMessage);
             } else {
@@ -477,7 +486,7 @@ protected boolean hasValidAnnotatedField(Object action, String fieldName, long p
             return false;
         }
         LOG.debug("Success: Matching annotated public field [{}] found for property of depth [{}] on Action [{}]",
-                field.getName(), paramDepth, action.getClass().getSimpleName());
+                field.getName(), paramDepth, actionClass.getSimpleName());
         if (paramDepth >= 1) {
             allowlistClass(field.getType());
         }
@@ -510,9 +519,16 @@ protected StrutsParameter getParameterAnnotation(AnnotatedElement element) {
         return element.getAnnotation(StrutsParameter.class);
     }
 
+    protected Class<?> ultimateClass(Object action) {
+        if (ProxyUtil.isProxy(action)) {
+            return ProxyUtil.ultimateTargetClass(action);
+        }
+        return action.getClass();
+    }
+
     protected BeanInfo getBeanInfo(Object action) {
         try {
-            return Introspector.getBeanInfo(action.getClass());
+            return ognlUtil.getBeanInfo(ultimateClass(action));
         } catch (IntrospectionException e) {
             LOG.warn("Error introspecting Action {} for parameter injection validation", action.getClass(), e);
             return null;
diff --git a/core/src/main/java/org/apache/struts2/ognl/SecurityMemberAccess.java b/core/src/main/java/org/apache/struts2/ognl/SecurityMemberAccess.java
index 64a8fa5a44..928dda78db 100644
--- a/core/src/main/java/org/apache/struts2/ognl/SecurityMemberAccess.java
+++ b/core/src/main/java/org/apache/struts2/ognl/SecurityMemberAccess.java
@@ -212,16 +212,17 @@ protected boolean checkAllowlist(Object target, Member member) {
             return true;
         }
 
+        Class<?> targetClass = target != null ? target.getClass() : null;
+
         if (!disallowProxyObjectAccess && ProxyUtil.isProxy(target)) {
-            // If `disallowProxyObjectAccess` is not set, allow resolving Hibernate entities to their underlying
-            // classes/members. This allows the allowlist capability to continue working and offer some level of
+            // If `disallowProxyObjectAccess` is not set, allow resolving Hibernate entities and Spring proxies to their
+            // underlying classes/members. This allows the allowlist capability to continue working and still offer
             // protection in applications where the developer has accepted the risk of allowing OGNL access to Hibernate
-            // entities. This is preferred to having to disable the allowlist capability entirely.
-            Object newTarget = ProxyUtil.getHibernateProxyTarget(target);
-            if (newTarget != target) {
-                logAllowlistHibernateEntity(target, newTarget);
-                target = newTarget;
-                member = ProxyUtil.resolveTargetMember(member, newTarget);
+            // entities and Spring proxies. This is preferred to having to disable the allowlist capability entirely.
+            Class<?> newTargetClass = ProxyUtil.ultimateTargetClass(target);
+            if (newTargetClass != targetClass) {
+                targetClass = newTargetClass;
+                member = ProxyUtil.resolveTargetMember(member, newTargetClass);
             }
         }
 
@@ -231,10 +232,10 @@ protected boolean checkAllowlist(Object target, Member member) {
                     memberClass, member, STRUTS_ALLOWLIST_CLASSES, STRUTS_ALLOWLIST_PACKAGE_NAMES);
             return false;
         }
-        if (target == null || target.getClass() == memberClass) {
+
+        if (targetClass == null || targetClass == memberClass) {
             return true;
         }
-        Class<?> targetClass = target.getClass();
         if (!isClassAllowlisted(targetClass)) {
             LOG.warn("Target class [{}] of target [{}] is not allowlisted! Add to '{}' or '{}' configuration.",
                     targetClass, target, STRUTS_ALLOWLIST_CLASSES, STRUTS_ALLOWLIST_PACKAGE_NAMES);
@@ -243,20 +244,6 @@ protected boolean checkAllowlist(Object target, Member member) {
         return true;
     }
 
-    private void logAllowlistHibernateEntity(Object original, Object resolved) {
-        if (!isDevMode && !LOG.isDebugEnabled()) {
-            return;
-        }
-        String msg = "Hibernate entity [{}] resolved to [{}] for purpose of OGNL allowlisting." +
-                " We don't recommend executing OGNL expressions against Hibernate entities, you may disallow this behaviour using the configuration `{}=true`.";
-        Object[] args = {original, resolved, StrutsConstants.STRUTS_DISALLOW_PROXY_OBJECT_ACCESS};
-        if (isDevMode) {
-            LOG.warn(msg, args);
-        } else {
-            LOG.debug(msg, args);
-        }
-    }
-
     protected boolean isClassAllowlisted(Class<?> clazz) {
         return allowlistClasses.contains(clazz)
                 || ALLOWLIST_REQUIRED_CLASSES.contains(clazz)
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 24ca62d8d3..500574d34e 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
@@ -18,18 +18,26 @@
  */
 package org.apache.struts2.interceptor.parameter;
 
+import org.aopalliance.intercept.Joinpoint;
+import org.aopalliance.intercept.MethodInterceptor;
+import org.apache.commons.lang3.ClassUtils;
 import org.apache.struts2.ActionContext;
 import org.apache.struts2.ModelDriven;
 import org.apache.struts2.StubValueStack;
-import org.apache.struts2.security.AcceptedPatternsChecker;
-import org.apache.struts2.security.NotExcludedAcceptedPatternsChecker;
-import org.apache.commons.lang3.ClassUtils;
 import org.apache.struts2.dispatcher.HttpParameters;
 import org.apache.struts2.dispatcher.Parameter;
+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.ThreadAllowlist;
+import org.apache.struts2.security.AcceptedPatternsChecker.IsAccepted;
+import org.apache.struts2.security.ExcludedPatternsChecker.IsExcluded;
+import org.apache.struts2.security.NotExcludedAcceptedPatternsChecker;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
+import org.springframework.aop.framework.ProxyFactory;
 
 import java.util.HashMap;
 import java.util.HashSet;
@@ -37,6 +45,7 @@
 import java.util.Map;
 import java.util.Set;
 
+import static org.apache.struts2.ognl.OgnlCacheFactory.CacheType.LRU;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
@@ -56,9 +65,15 @@ public void setUp() throws Exception {
         threadAllowlist = new ThreadAllowlist();
         parametersInterceptor.setThreadAllowlist(threadAllowlist);
 
+        var ognlUtil = new OgnlUtil(
+                new DefaultOgnlExpressionCacheFactory<>(String.valueOf(1000), LRU.toString()),
+                new DefaultOgnlBeanInfoCacheFactory<>(String.valueOf(1000), LRU.toString()),
+                new StrutsOgnlGuard());
+        parametersInterceptor.setOgnlUtil(ognlUtil);
+
         NotExcludedAcceptedPatternsChecker checker = mock(NotExcludedAcceptedPatternsChecker.class);
-        when(checker.isAccepted(anyString())).thenReturn(AcceptedPatternsChecker.IsAccepted.yes(""));
-        when(checker.isExcluded(anyString())).thenReturn(NotExcludedAcceptedPatternsChecker.IsExcluded.no(new HashSet<>()));
+        when(checker.isAccepted(anyString())).thenReturn(IsAccepted.yes(""));
+        when(checker.isExcluded(anyString())).thenReturn(IsExcluded.no(Set.of()));
         parametersInterceptor.setAcceptedPatterns(checker);
         parametersInterceptor.setExcludedPatterns(checker);
     }
@@ -94,174 +109,267 @@ private Set<Class<?>> getParentClasses(Class<?>... clazzes) {
         return set;
     }
 
+    /**
+     * Private String field cannot be injected even when annotated.
+     */
     @Test
     public void privateStrAnnotated() {
         testParameter(new FieldAction(), "privateStr", false);
     }
 
+    /**
+     * Public String field can be injected when annotated.
+     */
     @Test
     public void publicStrAnnotated() {
         testParameter(new FieldAction(), "publicStr", true);
         assertThat(threadAllowlist.getAllowlist()).isEmpty();
     }
 
+    /**
+     * Public String field cannot be injected when not annotated.
+     */
     @Test
     public void publicStrNotAnnotated() {
         testParameter(new FieldAction(), "publicStrNotAnnotated", false);
     }
 
+    /**
+     * Private Pojo field cannot be injected even when annotated with the appropriate depth.
+     */
     @Test
     public void privatePojoAnnotated() {
         testParameter(new FieldAction(), "privatePojo.key", false);
     }
 
+    /**
+     * Public Pojo field cannot be injected when annotated with depth zero.
+     */
     @Test
     public void publicPojoDepthZero() {
         testParameter(new FieldAction(), "publicPojoDepthZero.key", false);
     }
 
+    /**
+     * Public Pojo field can be injected when annotated with depth one.
+     */
     @Test
     public void publicPojoDepthOne() {
         testParameter(new FieldAction(), "publicPojoDepthOne.key", true);
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
     }
 
+    /**
+     * Public Pojo field can be injected when annotated with depth one, using the square bracket syntax.
+     */
     @Test
     public void publicPojoDepthOne_sqrBracket() {
         testParameter(new FieldAction(), "publicPojoDepthOne['key']", true);
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
     }
 
+    /**
+     * Public Pojo field can be injected when annotated with depth one, using the bracket syntax.
+     */
     @Test
     public void publicPojoDepthOne_bracket() {
         testParameter(new FieldAction(), "publicPojoDepthOne('key')", true);
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
     }
 
-    @Test
-    public void publicNestedPojoDepthOne() {
-        testParameter(new FieldAction(), "publicPojoDepthOne.key.key", false);
-    }
-
+    /**
+     * Public Pojo field can be injected when annotated with a depth greater than required.
+     */
     @Test
     public void publicPojoDepthTwo() {
         testParameter(new FieldAction(), "publicPojoDepthTwo.key", true);
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
     }
 
+    /**
+     * Public Pojo field cannot be injected two levels when only annotated with depth one.
+     */
+    @Test
+    public void publicNestedPojoDepthOne() {
+        testParameter(new FieldAction(), "publicPojoDepthOne.key.key", false);
+    }
+
+    /**
+     * Public Pojo field can be injected two levels when annotated with depth two.
+     */
     @Test
     public void publicNestedPojoDepthTwo() {
         testParameter(new FieldAction(), "publicPojoDepthTwo.key.key", true);
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
     }
 
+    /**
+     * Public Pojo field can be injected two levels when annotated with depth two, using the square bracket syntax.
+     */
     @Test
     public void publicNestedPojoDepthTwo_sqrBracket() {
         testParameter(new FieldAction(), "publicPojoDepthTwo['key']['key']", true);
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
     }
 
+    /**
+     * Public Pojo field can be injected two levels when annotated with depth two, using the bracket syntax.
+     */
     @Test
     public void publicNestedPojoDepthTwo_bracket() {
         testParameter(new FieldAction(), "publicPojoDepthTwo('key')('key')", true);
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
     }
 
+    /**
+     * Private String setting method cannot be injected even when annotated.
+     */
     @Test
     public void privateStrAnnotatedMethod() {
         testParameter(new MethodAction(), "privateStr", false);
     }
 
+    /**
+     * Public String setting method can be injected when annotated.
+     */
     @Test
     public void publicStrAnnotatedMethod() {
         testParameter(new MethodAction(), "publicStr", true);
         assertThat(threadAllowlist.getAllowlist()).isEmpty();
     }
 
+    /**
+     * Public String setting method cannot be injected when not annotated.
+     */
     @Test
     public void publicStrNotAnnotatedMethod() {
         testParameter(new MethodAction(), "publicStrNotAnnotated", false);
     }
 
+    /**
+     * Private Pojo returning method cannot be injected even when annotated with the appropriate depth.
+     */
     @Test
     public void privatePojoAnnotatedMethod() {
         testParameter(new MethodAction(), "privatePojo.key", false);
     }
 
+    /**
+     * Public Pojo returning method cannot be injected when annotated with depth zero.
+     */
     @Test
     public void publicPojoDepthZeroMethod() {
         testParameter(new MethodAction(), "publicPojoDepthZero.key", false);
     }
 
+    /**
+     * Public Pojo returning method can be injected when annotated with depth one.
+     */
     @Test
     public void publicPojoDepthOneMethod() {
         testParameter(new MethodAction(), "publicPojoDepthOne.key", true);
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
     }
 
+    /**
+     * Public Pojo returning method cannot be injected two levels when only annotated with depth one.
+     */
     @Test
     public void publicNestedPojoDepthOneMethod() {
         testParameter(new MethodAction(), "publicPojoDepthOne.key.key", false);
     }
 
+    /**
+     * Public Pojo returning method can be injected when annotated with a depth greater than required.
+     */
     @Test
     public void publicPojoDepthTwoMethod() {
         testParameter(new MethodAction(), "publicPojoDepthTwo.key", true);
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
     }
 
+    /**
+     * Public Pojo returning method can be injected two levels when annotated with depth two.
+     */
     @Test
     public void publicNestedPojoDepthTwoMethod() {
         testParameter(new MethodAction(), "publicPojoDepthTwo.key.key", true);
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Pojo.class));
     }
 
+    /**
+     * Public list of Pojo field cannot be injected when annotated with depth one.
+     */
     @Test
     public void publicPojoListDepthOne() {
         testParameter(new FieldAction(), "publicPojoListDepthOne[0].key", false);
     }
 
+    /**
+     * Public list of Pojo field can be injected when annotated with depth two.
+     */
     @Test
     public void publicPojoListDepthTwo() {
         testParameter(new FieldAction(), "publicPojoListDepthTwo[0].key", true);
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(List.class, Pojo.class));
     }
 
-    @Test
-    public void publicPojoMapDepthTwo() {
-        testParameter(new FieldAction(), "publicPojoMapDepthTwo['a'].key", true);
-        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Map.class, String.class, Pojo.class));
-    }
-
+    /**
+     * Public list of Pojo returning method cannot be injected when annotated with depth one.
+     */
     @Test
     public void publicPojoListDepthOneMethod() {
         testParameter(new MethodAction(), "publicPojoListDepthOne[0].key", false);
     }
 
+    /**
+     * Public list of Pojo returning method can be injected when annotated with depth two.
+     */
     @Test
     public void publicPojoListDepthTwoMethod() {
         testParameter(new MethodAction(), "publicPojoListDepthTwo[0].key", true);
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(List.class, Pojo.class));
     }
 
+    /**
+     * Public map of Pojo field can be injected when annotated with depth two.
+     */
+    @Test
+    public void publicPojoMapDepthTwo() {
+        testParameter(new FieldAction(), "publicPojoMapDepthTwo['a'].key", true);
+        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Map.class, String.class, Pojo.class));
+    }
+
+    /**
+     * Public map of Pojo returning method can be injected when annotated with depth two.
+     */
     @Test
     public void publicPojoMapDepthTwoMethod() {
         testParameter(new MethodAction(), "publicPojoMapDepthTwo['a'].key", true);
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Map.class, String.class, Pojo.class));
     }
 
+    /**
+     * Public String field can be injected even when not annotated, if transition mode is enabled.
+     */
     @Test
     public void publicStrNotAnnotated_transitionMode() {
         parametersInterceptor.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
         testParameter(new FieldAction(), "publicStrNotAnnotated", true);
     }
 
+    /**
+     * Public String setting method can be injected even when not annotated, if transition mode is enabled.
+     */
     @Test
     public void publicStrNotAnnotatedMethod_transitionMode() {
         parametersInterceptor.setRequireAnnotationsTransitionMode(Boolean.TRUE.toString());
         testParameter(new MethodAction(), "publicStrNotAnnotated", true);
     }
 
+    /**
+     * Models of ModelDriven actions can be injected without any annotations on the Action.
+     */
     @Test
     public void publicModelPojo() {
         var action = new ModelAction();
@@ -276,6 +384,27 @@ public void publicModelPojo() {
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Object.class, Pojo.class));
     }
 
+    /**
+     * Models of ModelDriven actions can be injected without any annotations on the Action, even when the Action is
+     * proxied.
+     */
+    @Test
+    public void publicModelPojo_proxied() {
+        var proxyFactory = new ProxyFactory(new ModelAction());
+        proxyFactory.setProxyTargetClass(true);
+        proxyFactory.addAdvice((MethodInterceptor) Joinpoint::proceed);
+        var proxiedAction = (ModelAction) proxyFactory.getProxy();
+
+        // Emulate ModelDrivenInterceptor running previously
+        var valueStack = new StubValueStack();
+        valueStack.push(proxiedAction.getModel());
+        ActionContext.of().withValueStack(valueStack).bind();
+
+        testParameter(proxiedAction, "name", true);
+        testParameter(proxiedAction, "name.nested", true);
+        assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Object.class, Pojo.class));
+    }
+
     static class FieldAction {
         @StrutsParameter
         private String privateStr;
diff --git a/core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessTest.java b/core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessTest.java
index e3d3fa589a..ee775fc40e 100644
--- a/core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessTest.java
+++ b/core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessTest.java
@@ -18,12 +18,12 @@
  */
 package org.apache.struts2.ognl;
 
+import ognl.MemberAccess;
+import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.struts2.TestBean;
 import org.apache.struts2.config.ConfigurationException;
 import org.apache.struts2.test.TestBean2;
 import org.apache.struts2.util.Foo;
-import ognl.MemberAccess;
-import org.apache.commons.lang3.reflect.FieldUtils;
 import org.hibernate.proxy.HibernateProxy;
 import org.hibernate.proxy.LazyInitializer;
 import org.junit.Before;
diff --git a/plugins/spring/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessProxyTest.java b/plugins/spring/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessProxyTest.java
index 43f82bfe51..160f0ef96c 100644
--- a/plugins/spring/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessProxyTest.java
+++ b/plugins/spring/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessProxyTest.java
@@ -20,12 +20,15 @@
 
 import org.apache.struts2.ActionProxy;
 import org.apache.struts2.XWorkJUnit4TestCase;
-import org.apache.struts2.config.providers.XmlConfigurationProvider;
 import org.apache.struts2.config.StrutsXmlConfigurationProvider;
+import org.apache.struts2.config.providers.XmlConfigurationProvider;
 import org.junit.Before;
 import org.junit.Test;
+import org.springframework.aop.MethodBeforeAdvice;
+import org.springframework.aop.framework.ProxyFactory;
 
 import java.lang.reflect.Member;
+import java.lang.reflect.Method;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Map;
@@ -175,4 +178,40 @@ public void nullPropertyName() {
         Object action = proxy.getAction();
         assertTrue(sma.isAccessible(context, action, proxyObjectProxyMember, null));
     }
+
+    /**
+     * When the allowlist is enabled and proxy object access is allowed, Spring proxies should be allowlisted based
+     * on their underlying target object. Class allowlisting should work as expected.
+     */
+    @Test
+    public void classInclusion_springProxy_allowProxyObjectAccess() throws Exception {
+        SpringService proxyObject = newSpringService();
+        Method proxyMethod = proxyObject.getClass().getMethod("doSomething");
+
+        sma.useEnforceAllowlistEnabled(Boolean.TRUE.toString());
+        sma.useDisallowProxyObjectAccess(Boolean.FALSE.toString());
+        sma.useAllowlistClasses(SpringServiceImpl.class.getName());
+
+        assertTrue(sma.checkAllowlist(proxyObject, proxyMethod));
+    }
+
+    private static SpringService newSpringService() {
+        SpringService target = new SpringServiceImpl();
+        ProxyFactory proxyFactory = new ProxyFactory(target);
+        proxyFactory.addAdvice(((MethodBeforeAdvice) (method, args, target1) -> {
+            System.out.println("Intercepting: " + method.getName());
+        }));
+        return (SpringService) proxyFactory.getProxy();
+    }
+}
+
+interface SpringService {
+    void doSomething();
+}
+
+class SpringServiceImpl implements SpringService {
+    @Override
+    public void doSomething() {
+        System.out.println("Doing something...");
+    }
 }


Reviews:
user: login: kusalk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-03T09:42:15Z
user: login: kusalk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-03T11:09:14Z
user: login: lukaszlenart
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T11:59:31Z
user: login: lukaszlenart
body: Left one comment, looks good!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-05T11:59:49Z
user: login: kusalk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T01:18:50Z

Review comments:
diff_hunk: @@ -243,20 +244,6 @@ protected boolean checkAllowlist(Object target, Member member) {
         return true;
     }
 
-    private void logAllowlistHibernateEntity(Object original, Object resolved) {
path: core/src/main/java/org/apache/struts2/ognl/SecurityMemberAccess.java
user: login: kusalk
body: I've decided to remove this warning as it's a tad aggressive. We've documented best practices in the migration guide and if users wish to still utilise Hibernate entities then that's up to them
created_at: 2025-03-03T09:42:15Z
author_association: MEMBER
diff_hunk: @@ -510,9 +519,16 @@ protected StrutsParameter getParameterAnnotation(AnnotatedElement element) {
         return element.getAnnotation(StrutsParameter.class);
     }
 
+    protected Class<?> ultimateClass(Object action) {
+        if (ProxyUtil.isProxy(action)) {
+            return ProxyUtil.ultimateTargetClass(action);
+        }
+        return action.getClass();
+    }
+
     protected BeanInfo getBeanInfo(Object action) {
         try {
-            return Introspector.getBeanInfo(action.getClass());
+            return ognlUtil.getBeanInfo(ultimateClass(action));
path: core/src/main/java/org/apache/struts2/interceptor/parameter/ParametersInterceptor.java
user: login: kusalk
body: When I implemented this class, I forgot `OgnlUtil` already had a cached variant of this capability. We are now using that, and resolving any proxies to ensure annotation detection works as expected.
created_at: 2025-03-03T11:03:54Z
author_association: MEMBER
path: core/src/test/java/org/apache/struts2/interceptor/parameter/StrutsParameterAnnotationTest.java
user: login: kusalk
body: I added comments for all the other tests in this class as I realised I didn't name them very well initially
created_at: 2025-03-03T11:04:57Z
author_association: MEMBER
diff_hunk: @@ -276,6 +384,27 @@ public void publicModelPojo() {
         assertThat(threadAllowlist.getAllowlist()).containsExactlyInAnyOrderElementsOf(getParentClasses(Object.class, Pojo.class));
     }
 
+    /**
+     * Models of ModelDriven actions can be injected without any annotations on the Action, even when the Action is
+     * proxied.
+     */
+    @Test
+    public void publicModelPojo_proxied() {
+        var proxyFactory = new ProxyFactory(new ModelAction());
path: core/src/test/java/org/apache/struts2/interceptor/parameter/StrutsParameterAnnotationTest.java
user: login: kusalk
body: We use the Spring proxy factory to create a CGLIB proxy like would occur when a transactional proxy is applied to a concrete Action class (like in the original bug report)
created_at: 2025-03-03T11:05:56Z
author_association: MEMBER
diff_hunk: @@ -212,16 +212,17 @@ protected boolean checkAllowlist(Object target, Member member) {
             return true;
         }
 
+        Class<?> targetClass = target != null ? target.getClass() : null;
+
         if (!disallowProxyObjectAccess && ProxyUtil.isProxy(target)) {
-            // If `disallowProxyObjectAccess` is not set, allow resolving Hibernate entities to their underlying
-            // classes/members. This allows the allowlist capability to continue working and offer some level of
+            // If `disallowProxyObjectAccess` is not set, allow resolving Hibernate entities and Spring proxies to their
+            // underlying classes/members. This allows the allowlist capability to continue working and still offer
             // protection in applications where the developer has accepted the risk of allowing OGNL access to Hibernate
-            // entities. This is preferred to having to disable the allowlist capability entirely.
-            Object newTarget = ProxyUtil.getHibernateProxyTarget(target);
-            if (newTarget != target) {
-                logAllowlistHibernateEntity(target, newTarget);
-                target = newTarget;
-                member = ProxyUtil.resolveTargetMember(member, newTarget);
+            // entities and Spring proxies. This is preferred to having to disable the allowlist capability entirely.
+            Class<?> newTargetClass = ProxyUtil.ultimateTargetClass(target);
path: core/src/main/java/org/apache/struts2/ognl/SecurityMemberAccess.java
user: login: kusalk
body: We replaced `#getHibernateProxyTarget` with `#ultimateTargetClass` which can also resolve Spring proxies. This allows the OGNL allowlist to function in the presence of Spring proxies in applications where `struts.disallowProxyObjectAccess` has been reverted to `false`.
created_at: 2025-03-03T11:07:09Z
author_association: MEMBER
diff_hunk: @@ -967,6 +969,22 @@ public void classInclusion_hibernateProxy_allowProxyObjectAccess() throws Except
         assertTrue(sma.checkAllowlist(proxyObject, proxyMethod));
     }
 
+    /**
+     * When the allowlist is enabled and proxy object access is allowed, Spring proxies should be allowlisted based
+     * on their underlying target object. Class allowlisting should work as expected.
+     */
+    @Test
+    public void classInclusion_springProxy_allowProxyObjectAccess() throws Exception {
+        SpringService proxyObject = newSpringService();
+        Method proxyMethod = proxyObject.getClass().getMethod("doSomething");
+
+        sma.useEnforceAllowlistEnabled(Boolean.TRUE.toString());
+        sma.useDisallowProxyObjectAccess(Boolean.FALSE.toString());
+        sma.useAllowlistClasses(SpringServiceImpl.class.getName());
+
+        assertTrue(sma.checkAllowlist(proxyObject, proxyMethod));
+    }
+
path: core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessTest.java
user: login: lukaszlenart
body: There is a dedicated `SecurityMemberAccessProxyTest` and maybe it would be good to move proxy related test cases there as well.
created_at: 2025-03-05T11:59:31Z
author_association: MEMBER
diff_hunk: @@ -967,6 +969,22 @@ public void classInclusion_hibernateProxy_allowProxyObjectAccess() throws Except
         assertTrue(sma.checkAllowlist(proxyObject, proxyMethod));
     }
 
+    /**
+     * When the allowlist is enabled and proxy object access is allowed, Spring proxies should be allowlisted based
+     * on their underlying target object. Class allowlisting should work as expected.
+     */
+    @Test
+    public void classInclusion_springProxy_allowProxyObjectAccess() throws Exception {
+        SpringService proxyObject = newSpringService();
+        Method proxyMethod = proxyObject.getClass().getMethod("doSomething");
+
+        sma.useEnforceAllowlistEnabled(Boolean.TRUE.toString());
+        sma.useDisallowProxyObjectAccess(Boolean.FALSE.toString());
+        sma.useAllowlistClasses(SpringServiceImpl.class.getName());
+
+        assertTrue(sma.checkAllowlist(proxyObject, proxyMethod));
+    }
+
path: core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessTest.java
user: login: kusalk
body: Done!
created_at: 2025-03-06T01:18:50Z
author_association: MEMBER

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-03-06T01:20:29Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=apache_struts&pullRequest=1237) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=apache_struts&pullRequest=1237&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=apache_struts&pullRequest=1237&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=apache_struts&pullRequest=1237&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [90.3% Coverage on New Code](https://sonarcloud.io/component_measures?id=apache_struts&pullRequest=1237&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=apache_struts&pullRequest=1237&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=apache_struts&pullRequest=1237)


author_association: NONE