diff --git a/plugins/cdi/pom.xml b/plugins/cdi/pom.xml
index 12a39b59cf..90910553be 100644
--- a/plugins/cdi/pom.xml
+++ b/plugins/cdi/pom.xml
@@ -39,6 +39,13 @@
provided
+
+ org.jboss.weld
+ weld-api
+ ${weld-api.version}
+ provided
+
+
org.jboss.weld
weld-core-impl
@@ -71,5 +78,7 @@
UTF-8
+
+ 6.0.Final
diff --git a/plugins/cdi/src/main/java/org/apache/struts2/cdi/CdiProxyService.java b/plugins/cdi/src/main/java/org/apache/struts2/cdi/CdiProxyService.java
new file mode 100644
index 0000000000..b5e4e1b5d0
--- /dev/null
+++ b/plugins/cdi/src/main/java/org/apache/struts2/cdi/CdiProxyService.java
@@ -0,0 +1,122 @@
+/*
+ * 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.cdi;
+
+import org.apache.commons.lang3.reflect.MethodUtils;
+import org.apache.struts2.inject.Inject;
+import org.apache.struts2.ognl.ProxyCacheFactory;
+import org.apache.struts2.util.StrutsProxyService;
+import org.jboss.weld.proxy.WeldClientProxy;
+
+import java.lang.reflect.Member;
+import java.lang.reflect.Method;
+
+import static java.lang.reflect.Modifier.isStatic;
+
+/**
+ * CDI-aware {@link org.apache.struts2.util.ProxyService}. Extends the default
+ * {@link StrutsProxyService} (Spring + Hibernate detection) with recognition of
+ * Weld client proxies, so {@code SecurityMemberAccess} can resolve the real
+ * target class of a normal-scoped CDI bean before evaluating the OGNL allowlist.
+ *
+ * @see WW-5604
+ */
+public class CdiProxyService extends StrutsProxyService {
+
+ private static final boolean WELD_AVAILABLE = isWeldAvailable();
+
+ private static boolean isWeldAvailable() {
+ try {
+ Class.forName("org.jboss.weld.proxy.WeldClientProxy");
+ return true;
+ } catch (ClassNotFoundException e) {
+ return false;
+ }
+ }
+
+ private final boolean weldAvailable;
+
+ @Inject
+ public CdiProxyService(ProxyCacheFactory, ?> proxyCacheFactory) {
+ this(proxyCacheFactory, WELD_AVAILABLE);
+ }
+
+ /**
+ * Package-private constructor so tests can exercise the Weld-absent fallback.
+ */
+ CdiProxyService(ProxyCacheFactory, ?> proxyCacheFactory, boolean weldAvailable) {
+ super(proxyCacheFactory);
+ this.weldAvailable = weldAvailable;
+ }
+
+ @Override
+ public boolean isProxy(Object object) {
+ return super.isProxy(object) || isWeldProxy(object);
+ }
+
+ @Override
+ public boolean isProxyMember(Member member, Object object) {
+ return super.isProxyMember(member, object) || isWeldProxyMember(member, object);
+ }
+
+ @Override
+ public Class> ultimateTargetClass(Object candidate) {
+ if (isWeldProxy(candidate)) {
+ return weldUltimateTargetClass(candidate);
+ }
+ return super.ultimateTargetClass(candidate);
+ }
+
+ private boolean isWeldProxy(Object object) {
+ if (!weldAvailable || object == null) {
+ return false;
+ }
+ try {
+ return object instanceof WeldClientProxy;
+ } catch (LinkageError ignored) {
+ return false;
+ }
+ }
+
+ private Class> weldUltimateTargetClass(Object candidate) {
+ try {
+ Object instance = ((WeldClientProxy) candidate).getMetadata().getContextualInstance();
+ return instance != null ? instance.getClass() : candidate.getClass();
+ } catch (LinkageError ignored) {
+ return candidate.getClass();
+ }
+ }
+
+ private boolean isWeldProxyMember(Member member, Object object) {
+ if (!weldAvailable) {
+ return false;
+ }
+ if (!isStatic(member.getModifiers()) && !isWeldProxy(object)) {
+ return false;
+ }
+ try {
+ if (member instanceof Method method) {
+ return MethodUtils.getMatchingMethod(WeldClientProxy.class, member.getName(), method.getParameterTypes()) != null;
+ }
+ return false;
+ } catch (LinkageError ignored) {
+ return false;
+ }
+ }
+}
diff --git a/plugins/cdi/src/main/resources/struts-plugin.xml b/plugins/cdi/src/main/resources/struts-plugin.xml
index b0458e9409..c69b2073fb 100644
--- a/plugins/cdi/src/main/resources/struts-plugin.xml
+++ b/plugins/cdi/src/main/resources/struts-plugin.xml
@@ -30,4 +30,7 @@
+
+
+
diff --git a/plugins/cdi/src/test/java/org/apache/struts2/cdi/CdiProxyServiceTest.java b/plugins/cdi/src/test/java/org/apache/struts2/cdi/CdiProxyServiceTest.java
new file mode 100644
index 0000000000..d75ca4cbc7
--- /dev/null
+++ b/plugins/cdi/src/test/java/org/apache/struts2/cdi/CdiProxyServiceTest.java
@@ -0,0 +1,160 @@
+/*
+ * 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.cdi;
+
+import jakarta.enterprise.inject.spi.Bean;
+import org.apache.struts2.ognl.StrutsProxyCacheFactory;
+import org.apache.struts2.util.ProxyService;
+import org.jboss.weld.bootstrap.api.helpers.RegistrySingletonProvider;
+import org.jboss.weld.environment.se.Weld;
+import org.jboss.weld.environment.se.WeldContainer;
+import org.jboss.weld.proxy.WeldClientProxy;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class CdiProxyServiceTest {
+
+ private static WeldContainer container;
+
+ private ProxyService proxyService;
+ private ProxiedFooService proxy;
+
+ @BeforeClass
+ public static void startContainer() {
+ container = new Weld().containerId(RegistrySingletonProvider.STATIC_INSTANCE).initialize();
+ }
+
+ @AfterClass
+ public static void stopContainer() {
+ container.shutdown();
+ }
+
+ @Before
+ public void setUp() {
+ proxyService = new CdiProxyService(new StrutsProxyCacheFactory<>("1000", "basic"));
+ proxy = container.select(ProxiedFooService.class).get();
+ }
+
+ @Test
+ public void weldClientProxyIsRecognisedAsProxy() {
+ assertThat(proxy).isInstanceOf(WeldClientProxy.class); // sanity: Weld really produced a proxy
+ assertThat(proxyService.isProxy(proxy)).isTrue();
+ }
+
+ @Test
+ public void ultimateTargetClassResolvesRealClass() {
+ assertThat(proxyService.ultimateTargetClass(proxy)).isEqualTo(ProxiedFooService.class);
+ }
+
+ @Test
+ public void weldProxyAccessorIsProxyMember() throws Exception {
+ Method getMetadata = proxy.getClass().getMethod("getMetadata");
+ assertThat(proxyService.isProxyMember(getMetadata, proxy)).isTrue();
+ }
+
+ @Test
+ public void realBeanMethodIsNotProxyMember() throws Exception {
+ Method getHello = proxy.getClass().getMethod("getHello");
+ assertThat(proxyService.isProxyMember(getHello, proxy)).isFalse();
+ }
+
+ @Test
+ public void plainObjectIsNotProxy() {
+ Object plain = new Object();
+ assertThat(proxyService.isProxy(plain)).isFalse();
+ assertThat(proxyService.ultimateTargetClass(plain)).isEqualTo(Object.class);
+ }
+
+ @Test
+ public void nullIsNotProxy() {
+ assertThat(proxyService.isProxy(null)).isFalse();
+ }
+
+ @Test
+ public void memberOfNonProxyObjectIsNotProxyMember() throws Exception {
+ Method getHello = ProxiedFooService.class.getMethod("getHello");
+ assertThat(proxyService.isProxyMember(getHello, new Object())).isFalse();
+ }
+
+ @Test
+ public void nonMethodMemberIsNotProxyMember() throws Exception {
+ Field field = SomeHolder.class.getDeclaredField("value");
+ assertThat(proxyService.isProxyMember(field, proxy)).isFalse();
+ }
+
+ @Test
+ public void withoutWeldFallsBackToBaseBehaviour() throws Exception {
+ ProxyService noWeld = new CdiProxyService(new StrutsProxyCacheFactory<>("1000", "basic"), false);
+ Method getMetadata = proxy.getClass().getMethod("getMetadata");
+
+ assertThat(noWeld.isProxy(proxy)).isFalse();
+ assertThat(noWeld.ultimateTargetClass(proxy)).isEqualTo(proxy.getClass());
+ assertThat(noWeld.isProxyMember(getMetadata, proxy)).isFalse();
+ }
+
+ @Test
+ public void ultimateTargetClassFallsBackToProxyClassWhenContextualInstanceIsNull() {
+ // Contract: a null contextual instance from Weld's Metadata must fall back to the proxy's own class, not NPE or return null
+ NullContextualInstanceProxy stub = new NullContextualInstanceProxy();
+ assertThat(proxyService.ultimateTargetClass(stub)).isEqualTo(stub.getClass());
+ }
+
+ @Test
+ public void ultimateTargetClassFallsBackToProxyClassWhenUnwrappingThrowsLinkageError() {
+ // Contract: a LinkageError while unwrapping the proxy must fall back to the proxy's own class rather than propagate
+ LinkageErrorProxy stub = new LinkageErrorProxy();
+ assertThat(proxyService.ultimateTargetClass(stub)).isEqualTo(stub.getClass());
+ }
+
+ private static class SomeHolder {
+ @SuppressWarnings("unused")
+ private String value;
+ }
+
+ private static class NullContextualInstanceProxy implements WeldClientProxy {
+ @Override
+ public Metadata getMetadata() {
+ return new Metadata() {
+ @Override
+ public Bean> getBean() {
+ return null;
+ }
+
+ @Override
+ public Object getContextualInstance() {
+ return null;
+ }
+ };
+ }
+ }
+
+ private static class LinkageErrorProxy implements WeldClientProxy {
+ @Override
+ public Metadata getMetadata() {
+ throw new NoClassDefFoundError("simulated missing Weld class");
+ }
+ }
+}
diff --git a/plugins/cdi/src/test/java/org/apache/struts2/cdi/ProxiedFooService.java b/plugins/cdi/src/test/java/org/apache/struts2/cdi/ProxiedFooService.java
new file mode 100644
index 0000000000..45b824d82b
--- /dev/null
+++ b/plugins/cdi/src/test/java/org/apache/struts2/cdi/ProxiedFooService.java
@@ -0,0 +1,33 @@
+/*
+ * 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.cdi;
+
+import jakarta.enterprise.context.ApplicationScoped;
+
+/**
+ * A normal-scoped CDI bean. Weld serves it through a client proxy that
+ * implements {@link org.jboss.weld.proxy.WeldClientProxy}.
+ */
+@ApplicationScoped
+public class ProxiedFooService {
+
+ public String getHello() {
+ return "Hello";
+ }
+}
diff --git a/plugins/cdi/src/test/java/org/apache/struts2/ognl/CdiSecurityMemberAccessProxyTest.java b/plugins/cdi/src/test/java/org/apache/struts2/ognl/CdiSecurityMemberAccessProxyTest.java
new file mode 100644
index 0000000000..fe1e6daf61
--- /dev/null
+++ b/plugins/cdi/src/test/java/org/apache/struts2/ognl/CdiSecurityMemberAccessProxyTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.ognl;
+
+import ognl.Ognl;
+import ognl.OgnlContext;
+import org.apache.struts2.cdi.CdiProxyService;
+import org.apache.struts2.cdi.ProxiedFooService;
+import org.apache.struts2.util.ProxyService;
+import org.jboss.weld.bootstrap.api.helpers.RegistrySingletonProvider;
+import org.jboss.weld.environment.se.Weld;
+import org.jboss.weld.environment.se.WeldContainer;
+import org.jboss.weld.proxy.WeldClientProxy;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.lang.reflect.Member;
+import java.lang.reflect.Method;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class CdiSecurityMemberAccessProxyTest {
+
+ private static WeldContainer container;
+
+ private OgnlContext context;
+ private SecurityMemberAccess sma;
+ private ProxiedFooService proxy;
+ private Member proxyMember; // WeldClientProxy#getMetadata — a proxy member
+ private Member realMember; // ProxiedFooService#getHello — a real bean member
+
+ @BeforeClass
+ public static void startContainer() {
+ container = new Weld().containerId(RegistrySingletonProvider.STATIC_INSTANCE).initialize();
+ }
+
+ @AfterClass
+ public static void stopContainer() {
+ container.shutdown();
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ ProxyService proxyService = new CdiProxyService(new StrutsProxyCacheFactory<>("1000", "basic"));
+ sma = new SecurityMemberAccess(null, null);
+ sma.setProxyService(proxyService);
+
+ context = (OgnlContext) Ognl.createDefaultContext(null);
+ proxy = container.select(ProxiedFooService.class).get();
+ proxyMember = proxy.getClass().getMethod("getMetadata");
+ realMember = proxy.getClass().getMethod("getHello");
+
+ assertThat(proxy).isInstanceOf(WeldClientProxy.class); // sanity
+ }
+
+ @Test
+ public void disallowProxyObjectAccessBlocksWeldProxy() {
+ sma.useDisallowProxyObjectAccess(Boolean.TRUE.toString());
+ // Object-level proxy access disallowed -> any member on the proxy is blocked.
+ assertThat(sma.isAccessible(context, proxy, realMember, "")).isFalse();
+ assertThat(sma.isAccessible(context, proxy, proxyMember, "")).isFalse();
+ }
+
+ @Test
+ public void disallowProxyMemberAccessBlocksWeldProxyMember() {
+ sma.useDisallowProxyObjectAccess(Boolean.FALSE.toString());
+ sma.useDisallowProxyMemberAccess(Boolean.TRUE.toString());
+ // The Weld proxy accessor is a proxy member -> blocked.
+ assertThat(sma.isAccessible(context, proxy, proxyMember, "")).isFalse();
+ }
+
+ /**
+ * When the allowlist is enabled and proxy object access is allowed, a Weld client proxy must be allowlisted based
+ * on its underlying target class ({@code ProxiedFooService}), not the proxy class. This is the core WW-5604 scenario.
+ */
+ @Test
+ public void classInclusion_weldProxy_allowProxyObjectAccess() throws Exception {
+ Method realMethod = proxy.getClass().getMethod("getHello");
+
+ sma.useEnforceAllowlistEnabled(Boolean.TRUE.toString());
+ sma.useDisallowProxyObjectAccess(Boolean.FALSE.toString());
+ sma.useAllowlistClasses(ProxiedFooService.class.getName());
+
+ assertThat(sma.checkAllowlist(proxy, realMethod)).isTrue();
+ }
+}