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 38e364022 WW-5844 fix(core)!: enforce HTTP method annotations across 
action hierarchies and plugin stacks (#2120)
38e364022 is described below

commit 38e3640220f5e194908c4dc168cb24101f989618
Author: g0w6y <[email protected]>
AuthorDate: Sun Nov 8 20:29:55 2026 +0530

    WW-5844 fix(core)!: enforce HTTP method annotations across action 
hierarchies and plugin stacks (#2120)
    
    * WW-5844 Enforce @HttpGet/@HttpPost on the implicit execute() method and 
add httpMethod to the REST and Bean Validation default stacks
    
    HttpMethodInterceptor misses an @Http* annotation in four distinct,
    independent ways: a method-level annotation on the implicit execute()
    method (gated by isMethodSpecified(), while the class-level check runs
    unconditionally), a class- or method-level annotation lost to a subclass
    that doesn't repeat it (plain Java annotation semantics, none of the
    Http* annotations is @Inherited), and the same loss one level further out
    for an annotation declared on an interface reached only through an
    intermediate superclass.
    
    restDefaultStack and beanValidationDefaultStack are the only two bundled
    interceptor stacks anywhere in core or any plugin that carry params but
    omit httpMethod entirely.
    
    HttpMethodInterceptor now always resolves the invoked method, including
    the implicit execute() case, and walks the full class/superclass/interface
    hierarchy for the nearest annotation at both the method and class level,
    checking every level's own implemented interfaces. httpMethod added to
    both stacks. A new, generic AccStackInvariantsTest (core) walks every
    stack definition in struts-default.xml and asserts any stack carrying
    params also carries httpMethod.
    
    Tests: AccHttpMethodTest, AccStackInvariantsTest (new), plus
    RestStackHttpMethodEnforcementTest and
    BeanValidationStackHttpMethodEnforcementTest. Full suites: core 3749/0
    failures (2 skipped), rest 245/0, bean-validation 25/0, convention 94/0,
    json 177/0.
    
    * WW-5844 fix(core)!: address HTTP method guard review
    
    Resolve parent interfaces transitively, ignore unrelated non-public and 
static
    method declarations, and preserve annotations on the resolved instance 
method.
    Keep explicit missing-method errors while allowing normal implicit 
invocation
    fallback after class and XML method restrictions.
    
    Move and license the test fixtures, assert 405/Allow and accepted controls,
    and cover actual REST mapper routing with native and tunneled requests.
    Document that REST restrictions check the transport method.
    
    BREAKING CHANGE: inherited HTTP method annotations and implicit execute()
    annotations are now enforced. REST and Bean Validation default stacks now
    enforce HTTP method restrictions. POST-based REST forms must allow POST in
    handler annotations and any XML http-method restriction.
---
 .../httpmethod/HttpMethodInterceptor.java          |  99 +++++++++-
 .../httpmethod/HttpMethodDefaultStacksTest.java    |  59 ++++++
 .../httpmethod/HttpMethodHierarchyActions.java     |  65 +++++++
 .../httpmethod/HttpMethodHierarchyTest.java        |  88 +++++++++
 .../httpmethod/HttpMethodInterceptorTest.java      | 201 +++++++++++++++++++++
 .../resources/struts-http-method-hierarchy.xml     |  33 ++++
 plugins/bean-validation/pom.xml                    |  10 +
 .../src/main/resources/struts-plugin.xml           |   1 +
 ...anValidationStackHttpMethodEnforcementTest.java |  82 +++++++++
 .../struts/beanvalidation/PostOnlyAction.java      |  34 ++++
 .../src/test/resources/ww-httpmethod-stack.xml     |  30 +++
 .../org/apache/struts2/rest/RestActionMapper.java  |   7 +
 plugins/rest/src/main/resources/struts-plugin.xml  |   1 +
 .../org/apache/struts2/rest/PostOnlyAction.java    |  34 ++++
 .../rest/RestStackHttpMethodEnforcementTest.java   | 199 ++++++++++++++++++++
 .../src/test/resources/ww-httpmethod-stack.xml     |  32 ++++
 16 files changed, 969 insertions(+), 6 deletions(-)

diff --git 
a/core/src/main/java/org/apache/struts2/interceptor/httpmethod/HttpMethodInterceptor.java
 
b/core/src/main/java/org/apache/struts2/interceptor/httpmethod/HttpMethodInterceptor.java
index c60267838..54b2988e3 100644
--- 
a/core/src/main/java/org/apache/struts2/interceptor/httpmethod/HttpMethodInterceptor.java
+++ 
b/core/src/main/java/org/apache/struts2/interceptor/httpmethod/HttpMethodInterceptor.java
@@ -18,6 +18,7 @@
  */
 package org.apache.struts2.interceptor.httpmethod;
 
+import org.apache.commons.lang3.ClassUtils;
 import org.apache.struts2.ActionInvocation;
 import org.apache.struts2.action.Action;
 import org.apache.struts2.interceptor.AbstractInterceptor;
@@ -32,11 +33,13 @@ import java.io.IOException;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.AnnotatedElement;
 import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
@@ -54,6 +57,11 @@ import java.util.stream.Collectors;
  * <p>
  * You can combine any of these annotations to achieve required allowed 
methods' filtering.
  * <p>
+ * Method annotations apply to the invoked method, including the implicit 
{@code execute()} method.
+ * Its annotation takes precedence; otherwise annotated declarations are 
looked up through the class
+ * and interface hierarchy. Class annotations are looked up only when no 
method annotation is found.
+ * Restrictions apply to {@link HttpServletRequest#getMethod()}, not a {@code 
_method} parameter.
+ * <p>
  * The allowed methods can also be declared on the action in XML with the 
{@code http-method} attribute.
  * When both the attribute and annotations are present, the request method 
must satisfy both.
  * <p>
@@ -97,18 +105,20 @@ public class HttpMethodInterceptor extends 
AbstractInterceptor {
             ((HttpMethodAware) 
(action)).setMethod(HttpMethod.parse(request.getMethod()));
         }
 
-        if (invocation.getProxy().isMethodSpecified()) {
-            Method method = 
action.getClass().getMethod(invocation.getProxy().getMethod());
-            if (AnnotationUtils.isAnnotatedBy(method, 
HTTP_METHOD_ANNOTATIONS)) {
+        Method method = resolveInvokedMethod(invocation);
+        if (method != null) {
+            AnnotatedElement methodElement = 
findNearestAnnotatedOverride(action.getClass(), method);
+            if (methodElement != null) {
                 LOG.debug("Action's method: {} annotated with: {}, checking if 
request: {} meets allowed methods!",
                     invocation.getProxy().getMethod(), 
AllowedHttpMethod.class.getSimpleName(), request.getMethod());
-                return doIntercept(invocation, method);
+                return doIntercept(invocation, methodElement);
             }
         }
-        if (AnnotationUtils.isAnnotatedBy(action.getClass(), 
HTTP_METHOD_ANNOTATIONS)) {
+        AnnotatedElement classElement = 
findNearestAnnotatedType(action.getClass());
+        if (classElement != null) {
             LOG.debug("Action: {} annotated with: {}, checking if request: {} 
meets allowed methods!",
                 action, AllowedHttpMethod.class.getSimpleName(), 
request.getMethod());
-            return doIntercept(invocation, action.getClass());
+            return doIntercept(invocation, classElement);
         }
 
         Set<HttpMethod> configuredMethods = 
invocation.getProxy().getConfig().getHttpMethods();
@@ -133,6 +143,83 @@ public class HttpMethodInterceptor extends 
AbstractInterceptor {
         return checkAllowedMethods(invocation, allowedMethods);
     }
 
+    private Method resolveInvokedMethod(ActionInvocation invocation) throws 
NoSuchMethodException {
+        String methodName = invocation.getProxy().getMethod();
+        if (methodName == null || methodName.isEmpty()) {
+            return null;
+        }
+        try {
+            return invocation.getAction().getClass().getMethod(methodName);
+        } catch (NoSuchMethodException e) {
+            if (invocation.getProxy().isMethodSpecified()) {
+                throw e;
+            }
+            // An absent implicit execute() can be handled by an 
UnknownHandler during invocation.
+            return null;
+        }
+    }
+
+    /** Resolves the invoked method first, then annotated declarations at each 
class and its interfaces. */
+    private AnnotatedElement findNearestAnnotatedOverride(Class<?> 
actionClass, Method method) {
+        if (!isPublicInstanceMethod(method)) {
+            return null;
+        }
+        if (AnnotationUtils.isAnnotatedBy(method, HTTP_METHOD_ANNOTATIONS)) {
+            return method;
+        }
+        for (Class<?> type = actionClass; type != null; type = 
type.getSuperclass()) {
+            AnnotatedElement found = declaredOverrideAnnotatedIn(type, method);
+            if (found != null) {
+                return found;
+            }
+            for (Class<?> iface : interfacesAtLevel(type)) {
+                found = declaredOverrideAnnotatedIn(iface, method);
+                if (found != null) {
+                    return found;
+                }
+            }
+        }
+        return null;
+    }
+
+    private AnnotatedElement declaredOverrideAnnotatedIn(Class<?> type, Method 
method) {
+        try {
+            Method candidate = type.getDeclaredMethod(method.getName(), 
method.getParameterTypes());
+            return isPublicInstanceMethod(candidate) && 
AnnotationUtils.isAnnotatedBy(candidate, HTTP_METHOD_ANNOTATIONS)
+                ? candidate : null;
+        } catch (NoSuchMethodException e) {
+            return null;
+        }
+    }
+
+    private boolean isPublicInstanceMethod(Method method) {
+        return Modifier.isPublic(method.getModifiers()) && 
!Modifier.isStatic(method.getModifiers());
+    }
+
+    /** Checks each class before its interfaces, including their 
superinterfaces, then its superclass. */
+    private AnnotatedElement findNearestAnnotatedType(Class<?> actionClass) {
+        for (Class<?> type = actionClass; type != null; type = 
type.getSuperclass()) {
+            if (AnnotationUtils.isAnnotatedBy(type, HTTP_METHOD_ANNOTATIONS)) {
+                return type;
+            }
+            for (Class<?> iface : interfacesAtLevel(type)) {
+                if (AnnotationUtils.isAnnotatedBy(iface, 
HTTP_METHOD_ANNOTATIONS)) {
+                    return iface;
+                }
+            }
+        }
+        return null;
+    }
+
+    private Set<Class<?>> interfacesAtLevel(Class<?> type) {
+        Set<Class<?>> interfaces = new LinkedHashSet<>();
+        for (Class<?> iface : type.getInterfaces()) {
+            interfaces.add(iface);
+            interfaces.addAll(ClassUtils.getAllInterfaces(iface));
+        }
+        return interfaces;
+    }
+
     private String checkAllowedMethods(ActionInvocation invocation, 
Set<HttpMethod> allowedMethods) throws Exception {
         HttpServletRequest request = 
invocation.getInvocationContext().getServletRequest();
         String requestedMethod = request.getMethod();
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodDefaultStacksTest.java
 
b/core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodDefaultStacksTest.java
new file mode 100644
index 000000000..b76adad82
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodDefaultStacksTest.java
@@ -0,0 +1,59 @@
+/*
+ * 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.interceptor.httpmethod;
+
+import org.apache.struts2.StrutsInternalTestCase;
+import org.apache.struts2.config.entities.InterceptorMapping;
+import org.apache.struts2.config.entities.InterceptorStackConfig;
+import org.apache.struts2.config.entities.PackageConfig;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/** Every bundled core stack that binds parameters also enforces HTTP method 
restrictions. */
+public class HttpMethodDefaultStacksTest extends StrutsInternalTestCase {
+
+    public void testEveryStackWithParamsAlsoHasHttpMethod() {
+        PackageConfig pc = configuration.getPackageConfig("struts-default");
+        Map<String, InterceptorStackConfig> stacks = allStacks(pc);
+        assertTrue("expected to find bundled interceptor-stacks", 
stacks.size() > 5);
+
+        List<String> violations = new ArrayList<>();
+        for (Map.Entry<String, InterceptorStackConfig> e : stacks.entrySet()) {
+            String name = e.getKey();
+            List<String> names = 
e.getValue().getInterceptors().stream().map(InterceptorMapping::getName).toList();
+            if (names.contains("params") && !names.contains("httpMethod")) {
+                violations.add(name);
+            }
+        }
+        assertTrue("stacks binding params but not enforcing httpMethod: " + 
violations, violations.isEmpty());
+    }
+
+    private Map<String, InterceptorStackConfig> allStacks(PackageConfig pc) {
+        Map<String, Object> all = pc.getAllInterceptorConfigs();
+        Map<String, InterceptorStackConfig> out = new 
java.util.LinkedHashMap<>();
+        for (Map.Entry<String, Object> e : all.entrySet()) {
+            if (e.getValue() instanceof InterceptorStackConfig stackConfig) {
+                out.put(e.getKey(), stackConfig);
+            }
+        }
+        return out;
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodHierarchyActions.java
 
b/core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodHierarchyActions.java
new file mode 100644
index 000000000..49e1096af
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodHierarchyActions.java
@@ -0,0 +1,65 @@
+/*
+ * 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.interceptor.httpmethod;
+
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.interceptor.httpmethod.HttpPost;
+
+public class HttpMethodHierarchyActions {
+    public static final java.util.concurrent.atomic.AtomicInteger RAN = new 
java.util.concurrent.atomic.AtomicInteger();
+
+    public static class ExecutePost extends ActionSupport {
+        @HttpPost
+        @Override
+        public String execute() { RAN.incrementAndGet(); return SUCCESS; }
+    }
+    public static class NamedPost extends ActionSupport {
+        @HttpPost
+        public String save() { RAN.incrementAndGet(); return SUCCESS; }
+    }
+    @HttpPost
+    public static class ClassPost extends ActionSupport {
+        @Override
+        public String execute() { RAN.incrementAndGet(); return SUCCESS; }
+    }
+    public static class SubOfClassPost extends ClassPost { }
+    public static class BaseMethodPost extends ActionSupport {
+        @HttpPost
+        public String save() { RAN.incrementAndGet(); return SUCCESS; }
+    }
+    public static class SubOverride extends BaseMethodPost {
+        @Override
+        public String save() { RAN.incrementAndGet(); return SUCCESS; }
+    }
+
+    public interface MethodPostMarker {
+        @HttpPost
+        default String save() { 
HttpMethodHierarchyActions.RAN.incrementAndGet(); return ActionSupport.SUCCESS; 
}
+    }
+    public static class ImplementsMethodMarker extends ActionSupport 
implements MethodPostMarker { }
+    public static class SubOfImplementsMethodMarker extends 
ImplementsMethodMarker { }
+
+    @HttpPost
+    public interface ClassPostMarker { }
+    public static class ImplementsClassMarker extends ActionSupport implements 
ClassPostMarker {
+        @Override
+        public String execute() { RAN.incrementAndGet(); return SUCCESS; }
+    }
+    public static class SubOfImplementsClassMarker extends 
ImplementsClassMarker { }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodHierarchyTest.java
 
b/core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodHierarchyTest.java
new file mode 100644
index 000000000..150c9c408
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodHierarchyTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.interceptor.httpmethod;
+
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.ActionProxy;
+import org.apache.struts2.StrutsInternalTestCase;
+import org.apache.struts2.config.StrutsXmlConfigurationProvider;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.util.Map;
+
+public class HttpMethodHierarchyTest extends StrutsInternalTestCase {
+
+    private MockHttpServletResponse run(String action, String verb) throws 
Exception {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider("struts-http-method-hierarchy.xml"));
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        Map<String, Object> context = ActionContext.of()
+            .withServletRequest(new MockHttpServletRequest(verb, "/" + action))
+            .withServletResponse(response).getContextMap();
+        HttpMethodHierarchyActions.RAN.set(0);
+        ActionProxy proxy = actionProxyFactory.createActionProxy("", action, 
null, context, false, true);
+        proxy.execute();
+        return response;
+    }
+
+    private void assertGetRejected(String action) throws Exception {
+        MockHttpServletResponse response = run(action, "GET");
+        assertEquals(405, response.getStatus());
+        assertEquals("POST", response.getHeader("Allow"));
+        assertEquals("action must not run", 0, 
HttpMethodHierarchyActions.RAN.get());
+    }
+
+    public void testControlNamedMethodGetRejected() throws Exception {
+        assertGetRejected("NamedPost");
+    }
+
+    public void testControlClassLevelGetRejected() throws Exception {
+        assertGetRejected("ClassPost");
+    }
+
+    public void testExecuteMethodAnnotationGetRejected() throws Exception {
+        assertGetRejected("ExecutePost");
+    }
+
+    public void testSubclassOfClassLevelGetRejected() throws Exception {
+        assertGetRejected("SubOfClassPost");
+    }
+
+    public void testOverriddenMethodRetainsAnnotation() throws Exception {
+        assertGetRejected("SubOverride");
+    }
+
+    public void testSubclassOfInterfaceDefaultMethodGetRejected() throws 
Exception {
+        assertGetRejected("SubOfImplementsMethodMarker");
+    }
+
+    public void testSubclassOfImplementedInterfaceClassLevelGetRejected() 
throws Exception {
+        assertGetRejected("SubOfImplementsClassMarker");
+    }
+
+    public void testPostAllowedForEveryAnnotatedActionShape() throws Exception 
{
+        for (String action : new String[]{"NamedPost", "ClassPost", 
"ExecutePost", "SubOfClassPost",
+                "SubOverride", "SubOfImplementsMethodMarker", 
"SubOfImplementsClassMarker"}) {
+            MockHttpServletResponse response = run(action, "POST");
+            assertEquals(action, 200, response.getStatus());
+            assertNull(action, response.getHeader("Allow"));
+            assertEquals(action, 1, HttpMethodHierarchyActions.RAN.get());
+        }
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodInterceptorTest.java
 
b/core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodInterceptorTest.java
index 63ad8118f..f22651116 100644
--- 
a/core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodInterceptorTest.java
+++ 
b/core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodInterceptorTest.java
@@ -540,6 +540,207 @@ public class HttpMethodInterceptorTest extends 
StrutsInternalTestCase {
         assertEquals(HttpServletResponse.SC_METHOD_NOT_ALLOWED, 
response.getStatus());
     }
 
+    public void testMissingExplicitMethodThrowsForGet() throws Exception {
+        assertMissingExplicitMethodThrows("GET");
+    }
+
+    public void testMissingExplicitMethodThrowsForPost() throws Exception {
+        assertMissingExplicitMethodThrows("POST");
+    }
+
+    private void assertMissingExplicitMethodThrows(String httpMethod) throws 
Exception {
+        prepareActionInvocation(new HttpMethodsTestAction(), actionConfig());
+        actionProxy.setMethod("missingMethod");
+        actionProxy.setMethodSpecified(true);
+        MockHttpServletResponse response = prepareRequest(httpMethod);
+
+        try {
+            interceptor.intercept(invocation);
+            fail("An explicitly configured missing method must throw");
+        } catch (NoSuchMethodException expected) {
+            assertTrue(expected.getMessage().contains("missingMethod"));
+        }
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+    }
+
+    public void testMissingImplicitExecuteReachesInvocation() throws Exception 
{
+        prepareActionInvocation(new Object(), actionConfig());
+        actionProxy.setMethod("execute");
+        actionProxy.setMethodSpecified(false);
+        invocation.setResultCode("unknown-handler-result");
+        MockHttpServletResponse response = prepareRequest("GET");
+
+        assertEquals("unknown-handler-result", 
interceptor.intercept(invocation));
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+    }
+
+    public void testMissingImplicitExecuteStillChecksClassAnnotation() throws 
Exception {
+        prepareActionInvocation(new PostOnlyWithoutExecute(), actionConfig());
+        actionProxy.setMethod("execute");
+        actionProxy.setMethodSpecified(false);
+        invocation.setResultCode("success");
+        MockHttpServletResponse response = prepareRequest("GET");
+
+        assertEquals(Action.NONE, interceptor.intercept(invocation));
+        assertEquals(HttpServletResponse.SC_METHOD_NOT_ALLOWED, 
response.getStatus());
+        assertEquals("POST", response.getHeader("Allow"));
+
+        response = prepareRequest("POST");
+        assertEquals("success", interceptor.intercept(invocation));
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+    }
+
+    public void testMissingImplicitExecuteStillChecksConfiguredHttpMethods() 
throws Exception {
+        ActionConfig config = new ActionConfig.Builder(actionConfig())
+            .addHttpMethods(List.of(HttpMethod.POST))
+            .build();
+        prepareActionInvocation(new Object(), config);
+        actionProxy.setMethod("execute");
+        actionProxy.setMethodSpecified(false);
+        invocation.setResultCode("success");
+        MockHttpServletResponse response = prepareRequest("GET");
+
+        assertEquals(Action.NONE, interceptor.intercept(invocation));
+        assertEquals(HttpServletResponse.SC_METHOD_NOT_ALLOWED, 
response.getStatus());
+        assertEquals("POST", response.getHeader("Allow"));
+
+        response = prepareRequest("POST");
+        assertEquals("success", interceptor.intercept(invocation));
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+    }
+
+    public void testMethodAnnotationOnSuperinterfaceIsEnforced() throws 
Exception {
+        assertPostOnly(new TransitiveMethodAction(), "save");
+    }
+
+    public void testTypeAnnotationOnSuperinterfaceIsEnforced() throws 
Exception {
+        assertPostOnly(new TransitiveTypeAction(), "execute");
+    }
+
+    public void 
testInheritedClassMethodAnnotationPrecedesInterfaceAnnotation() throws 
Exception {
+        assertPostOnly(new ClassMethodAndInterfaceAction(), "save");
+    }
+
+    private void assertPostOnly(Object action, String method) throws Exception 
{
+        prepareActionInvocation(action, actionConfig());
+        actionProxy.setMethod(method);
+        actionProxy.setMethodSpecified(true);
+        invocation.setResultCode("success");
+        MockHttpServletResponse response = prepareRequest("GET");
+
+        assertEquals(Action.NONE, interceptor.intercept(invocation));
+        assertEquals(HttpServletResponse.SC_METHOD_NOT_ALLOWED, 
response.getStatus());
+        assertEquals("POST", response.getHeader("Allow"));
+
+        response = prepareRequest("POST");
+        assertEquals("success", interceptor.intercept(invocation));
+        assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+    }
+
+    public void testPrivateAncestorMethodDoesNotRestrictPublicActionMethod() 
throws Exception {
+        assertUnrestrictedMethod(new PublicMethodOverPrivateHelperAction());
+    }
+
+    public void 
testPrivateStaticAncestorMethodDoesNotRestrictPublicActionMethod() throws 
Exception {
+        assertUnrestrictedMethod(new PublicMethodOverStaticHelperAction());
+    }
+
+    public void 
testPackagePrivateAncestorMethodDoesNotRestrictPublicActionMethod() throws 
Exception {
+        assertUnrestrictedMethod(new PublicMethodOverPackageHelperAction());
+    }
+
+    public void testStaticInterfaceMethodDoesNotRestrictPublicActionMethod() 
throws Exception {
+        assertUnrestrictedMethod(new PublicMethodWithStaticInterfaceAction());
+    }
+
+    private void assertUnrestrictedMethod(Object action) throws Exception {
+        prepareActionInvocation(action, actionConfig());
+        actionProxy.setMethod("save");
+        actionProxy.setMethodSpecified(true);
+        invocation.setResultCode("success");
+        for (String method : List.of("GET", "POST")) {
+            MockHttpServletResponse response = prepareRequest(method);
+            assertEquals("success", interceptor.intercept(invocation));
+            assertEquals(HttpServletResponse.SC_OK, response.getStatus());
+        }
+    }
+
+    @HttpPost
+    public static class PostOnlyWithoutExecute {
+    }
+
+    public interface PostMethod {
+        @HttpPost
+        String save();
+    }
+
+    public interface DerivedPostMethod extends PostMethod {
+    }
+
+    public static class TransitiveMethodAction implements DerivedPostMethod {
+        public String save() { return "success"; }
+    }
+
+    @HttpPost
+    public interface PostType {
+    }
+
+    public interface DerivedPostType extends PostType {
+    }
+
+    public static class TransitiveTypeAction implements DerivedPostType {
+        public String execute() { return "success"; }
+    }
+
+    public static class PostMethodBase {
+        @HttpPost
+        public String save() { return "success"; }
+    }
+
+    public interface GetMethod {
+        @HttpGet
+        String save();
+    }
+
+    public static class ClassMethodAndInterfaceAction extends PostMethodBase 
implements GetMethod {
+    }
+
+    public static class PrivateHelperBase {
+        @HttpPost
+        private String save() { return "helper"; }
+    }
+
+    public static class PublicMethodOverPrivateHelperAction extends 
PrivateHelperBase {
+        public String save() { return "success"; }
+    }
+
+    public static class StaticHelperBase {
+        @HttpPost
+        private static String save() { return "helper"; }
+    }
+
+    public static class PublicMethodOverStaticHelperAction extends 
StaticHelperBase {
+        public String save() { return "success"; }
+    }
+
+    public static class PackageHelperBase {
+        @HttpPost
+        String save() { return "helper"; }
+    }
+
+    public static class PublicMethodOverPackageHelperAction extends 
PackageHelperBase {
+        public String save() { return "success"; }
+    }
+
+    public interface StaticMethod {
+        @HttpPost
+        static String save() { return "helper"; }
+    }
+
+    public static class PublicMethodWithStaticInterfaceAction implements 
StaticMethod {
+        public String save() { return "success"; }
+    }
+
     private ActionProxy proxyFor(String namespace, String actionName, String 
httpMethod, MockHttpServletResponse response) {
         loadConfigurationProviders(new StrutsXmlConfigurationProvider(
             "org/apache/struts2/config/providers/struts-http-method.xml"));
diff --git a/core/src/test/resources/struts-http-method-hierarchy.xml 
b/core/src/test/resources/struts-http-method-hierarchy.xml
new file mode 100644
index 000000000..90a84e075
--- /dev/null
+++ b/core/src/test/resources/struts-http-method-hierarchy.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+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.
+-->
+<!DOCTYPE struts PUBLIC "-//Apache Software Foundation//DTD Struts 
Configuration 7.5//EN" "https://struts.apache.org/dtds/struts-7.5.dtd";>
+<struts>
+    <package name="http-method-hierarchy">
+        <interceptors><interceptor name="httpMethod" 
class="org.apache.struts2.interceptor.httpmethod.HttpMethodInterceptor"/></interceptors>
+        <default-interceptor-ref name="httpMethod"/>
+        <action name="ExecutePost" 
class="org.apache.struts2.interceptor.httpmethod.HttpMethodHierarchyActions$ExecutePost"/>
+        <action name="NamedPost" 
class="org.apache.struts2.interceptor.httpmethod.HttpMethodHierarchyActions$NamedPost"
 method="save"/>
+        <action name="ClassPost" 
class="org.apache.struts2.interceptor.httpmethod.HttpMethodHierarchyActions$ClassPost"/>
+        <action name="SubOfClassPost" 
class="org.apache.struts2.interceptor.httpmethod.HttpMethodHierarchyActions$SubOfClassPost"/>
+        <action name="SubOverride" 
class="org.apache.struts2.interceptor.httpmethod.HttpMethodHierarchyActions$SubOverride"
 method="save"/>
+        <action name="SubOfImplementsMethodMarker" 
class="org.apache.struts2.interceptor.httpmethod.HttpMethodHierarchyActions$SubOfImplementsMethodMarker"
 method="save"/>
+        <action name="SubOfImplementsClassMarker" 
class="org.apache.struts2.interceptor.httpmethod.HttpMethodHierarchyActions$SubOfImplementsClassMarker"/>
+    </package>
+</struts>
diff --git a/plugins/bean-validation/pom.xml b/plugins/bean-validation/pom.xml
index a377e1ba0..fa332c99f 100644
--- a/plugins/bean-validation/pom.xml
+++ b/plugins/bean-validation/pom.xml
@@ -73,6 +73,16 @@
             <artifactId>jakarta.activation-api</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-web</artifactId>
+            <scope>test</scope>
+        </dependency>
 
     </dependencies>
 
diff --git a/plugins/bean-validation/src/main/resources/struts-plugin.xml 
b/plugins/bean-validation/src/main/resources/struts-plugin.xml
index 80b3111cd..d4ee1217c 100644
--- a/plugins/bean-validation/src/main/resources/struts-plugin.xml
+++ b/plugins/bean-validation/src/main/resources/struts-plugin.xml
@@ -42,6 +42,7 @@
                 <interceptor-ref name="exception"/>
                 <interceptor-ref name="alias"/>
                 <interceptor-ref name="servletConfig"/>
+                <interceptor-ref name="httpMethod"/>
                 <interceptor-ref name="i18n"/>
                 <interceptor-ref name="prepare"/>
                 <interceptor-ref name="chain"/>
diff --git 
a/plugins/bean-validation/src/test/java/org/apache/struts/beanvalidation/BeanValidationStackHttpMethodEnforcementTest.java
 
b/plugins/bean-validation/src/test/java/org/apache/struts/beanvalidation/BeanValidationStackHttpMethodEnforcementTest.java
new file mode 100644
index 000000000..9ca391f9e
--- /dev/null
+++ 
b/plugins/bean-validation/src/test/java/org/apache/struts/beanvalidation/BeanValidationStackHttpMethodEnforcementTest.java
@@ -0,0 +1,82 @@
+/*
+ * 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.struts.beanvalidation;
+
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.ActionProxy;
+import org.apache.struts2.StrutsStatics;
+import org.apache.struts2.XWorkTestCase;
+import org.apache.struts2.config.DefaultPropertiesProvider;
+import org.apache.struts2.config.StrutsBeanSelectionProvider;
+import org.apache.struts2.config.StrutsXmlConfigurationProvider;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/** The Bean Validation default stack enforces HTTP method annotations. */
+public class BeanValidationStackHttpMethodEnforcementTest extends 
XWorkTestCase {
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        loadConfigurationProviders(
+                new StrutsXmlConfigurationProvider("struts-default.xml"),
+                new StrutsXmlConfigurationProvider("struts-plugin.xml"),
+                new StrutsXmlConfigurationProvider("ww-httpmethod-stack.xml"),
+                new DefaultPropertiesProvider(),
+                new StrutsBeanSelectionProvider());
+    }
+
+    private MockHttpServletResponse invoke(String namespace, String verb) 
throws Exception {
+        PostOnlyAction.invoked = false;
+        MockHttpServletRequest request = new MockHttpServletRequest(verb, 
namespace + "/mark");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put(StrutsStatics.HTTP_REQUEST, request);
+        ctx.put(StrutsStatics.HTTP_RESPONSE, response);
+        
ctx.putAll(ActionContext.of(ctx).withParameters(org.apache.struts2.dispatcher.HttpParameters.create(new
 HashMap<String, Object>()).build()).getContextMap());
+        ActionProxy proxy = actionProxyFactory.createActionProxy(namespace, 
"mark", null, ctx);
+        proxy.execute();
+        return response;
+    }
+
+    private void assertGetRejected(String namespace) throws Exception {
+        MockHttpServletResponse response = invoke(namespace, "GET");
+        assertEquals(405, response.getStatus());
+        assertEquals("POST", response.getHeader("Allow"));
+        assertFalse("action must not run", PostOnlyAction.invoked);
+    }
+
+    public void testControlCoreDefaultStackRejectsGetOnPostOnlyAction() throws 
Exception {
+        assertGetRejected("/core");
+    }
+
+    public void testBeanValidationDefaultStackRejectsGetOnPostOnlyAction() 
throws Exception {
+        assertGetRejected("/bv");
+    }
+
+    public void testBeanValidationDefaultStackAllowsPostOnPostOnlyAction() 
throws Exception {
+        MockHttpServletResponse response = invoke("/bv", "POST");
+        assertEquals(200, response.getStatus());
+        assertNull(response.getHeader("Allow"));
+        assertTrue("action must run", PostOnlyAction.invoked);
+    }
+}
diff --git 
a/plugins/bean-validation/src/test/java/org/apache/struts/beanvalidation/PostOnlyAction.java
 
b/plugins/bean-validation/src/test/java/org/apache/struts/beanvalidation/PostOnlyAction.java
new file mode 100644
index 000000000..91826f9f6
--- /dev/null
+++ 
b/plugins/bean-validation/src/test/java/org/apache/struts/beanvalidation/PostOnlyAction.java
@@ -0,0 +1,34 @@
+/*
+ * 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.struts.beanvalidation;
+
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.interceptor.httpmethod.HttpPost;
+
+/** Action that declares itself POST-only. */
+@HttpPost
+public class PostOnlyAction extends ActionSupport {
+    public static volatile boolean invoked;
+
+    @Override
+    public String execute() {
+        invoked = true;
+        return NONE;
+    }
+}
diff --git a/plugins/bean-validation/src/test/resources/ww-httpmethod-stack.xml 
b/plugins/bean-validation/src/test/resources/ww-httpmethod-stack.xml
new file mode 100644
index 000000000..3898b9dda
--- /dev/null
+++ b/plugins/bean-validation/src/test/resources/ww-httpmethod-stack.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+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.
+-->
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 6.0//EN"
+        "https://struts.apache.org/dtds/struts-6.0.dtd";>
+<struts>
+    <package name="beanValidationPkg" namespace="/bv" 
extends="struts-bean-validation">
+        <action name="mark" 
class="org.apache.struts.beanvalidation.PostOnlyAction"/>
+    </package>
+    <package name="corePkg" namespace="/core" extends="struts-default">
+        <action name="mark" 
class="org.apache.struts.beanvalidation.PostOnlyAction"/>
+    </package>
+</struts>
diff --git 
a/plugins/rest/src/main/java/org/apache/struts2/rest/RestActionMapper.java 
b/plugins/rest/src/main/java/org/apache/struts2/rest/RestActionMapper.java
index 202caeb77..9aef224d2 100644
--- a/plugins/rest/src/main/java/org/apache/struts2/rest/RestActionMapper.java
+++ b/plugins/rest/src/main/java/org/apache/struts2/rest/RestActionMapper.java
@@ -86,6 +86,13 @@ import java.util.HashMap;
  * To simulate the HTTP methods PUT and DELETE, since they aren't supported by 
HTML,
  * the HTTP parameter "_method" will be used.
  * </p>
+ * <p>
+ * The {@code httpMethod} interceptor checks the actual request method, not 
{@code _method}.
+ * A handler used by a POST form must allow POST, even when {@code _method} 
routes it to
+ * {@code update} or {@code destroy}. {@code @HttpPut} and {@code @HttpDelete} 
alone require
+ * actual PUT and DELETE requests; add {@code @HttpPost} to also accept 
tunneled POST forms.
+ * XML {@code http-method} restrictions must likewise allow the actual request 
method.
+ * </p>
  * <!-- END SNIPPET: description -->
  */
 public class RestActionMapper extends DefaultActionMapper {
diff --git a/plugins/rest/src/main/resources/struts-plugin.xml 
b/plugins/rest/src/main/resources/struts-plugin.xml
index c2116856e..37a91731f 100644
--- a/plugins/rest/src/main/resources/struts-plugin.xml
+++ b/plugins/rest/src/main/resources/struts-plugin.xml
@@ -76,6 +76,7 @@
                 <interceptor-ref name="exception"/>
                 <interceptor-ref name="alias"/>
                 <interceptor-ref name="servletConfig"/>
+                <interceptor-ref name="httpMethod"/>
                 <interceptor-ref name="messages">
                     <param name="operationMode">AUTOMATIC</param>
                 </interceptor-ref>
diff --git 
a/plugins/rest/src/test/java/org/apache/struts2/rest/PostOnlyAction.java 
b/plugins/rest/src/test/java/org/apache/struts2/rest/PostOnlyAction.java
new file mode 100644
index 000000000..dca1a48d0
--- /dev/null
+++ b/plugins/rest/src/test/java/org/apache/struts2/rest/PostOnlyAction.java
@@ -0,0 +1,34 @@
+/*
+ * 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.rest;
+
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.interceptor.httpmethod.HttpPost;
+
+/** State-changing action that declares itself POST-only. */
+@HttpPost
+public class PostOnlyAction extends ActionSupport {
+    public static volatile boolean invoked;
+
+    @Override
+    public String execute() {
+        invoked = true;
+        return NONE;
+    }
+}
diff --git 
a/plugins/rest/src/test/java/org/apache/struts2/rest/RestStackHttpMethodEnforcementTest.java
 
b/plugins/rest/src/test/java/org/apache/struts2/rest/RestStackHttpMethodEnforcementTest.java
new file mode 100644
index 000000000..ebd541741
--- /dev/null
+++ 
b/plugins/rest/src/test/java/org/apache/struts2/rest/RestStackHttpMethodEnforcementTest.java
@@ -0,0 +1,199 @@
+/*
+ * 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.rest;
+
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.ActionProxy;
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.StrutsStatics;
+import org.apache.struts2.XWorkTestCase;
+import org.apache.struts2.config.DefaultPropertiesProvider;
+import org.apache.struts2.config.StrutsBeanSelectionProvider;
+import org.apache.struts2.config.StrutsXmlConfigurationProvider;
+import org.apache.struts2.dispatcher.HttpParameters;
+import org.apache.struts2.dispatcher.mapper.ActionMapping;
+import org.apache.struts2.interceptor.httpmethod.HttpDelete;
+import org.apache.struts2.interceptor.httpmethod.HttpMethod;
+import org.apache.struts2.interceptor.httpmethod.HttpMethodAware;
+import org.apache.struts2.interceptor.httpmethod.HttpPost;
+import org.apache.struts2.interceptor.httpmethod.HttpPut;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/** The REST default stack enforces HTTP method annotations. */
+public class RestStackHttpMethodEnforcementTest extends XWorkTestCase {
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        loadConfigurationProviders(
+                new StrutsXmlConfigurationProvider("struts-default.xml"),
+                new StrutsXmlConfigurationProvider("struts-plugin.xml"),
+                new StrutsXmlConfigurationProvider("ww-httpmethod-stack.xml"),
+                new DefaultPropertiesProvider(),
+                new StrutsBeanSelectionProvider());
+    }
+
+    private MockHttpServletResponse invoke(String namespace, String verb) 
throws Exception {
+        PostOnlyAction.invoked = false;
+        MockHttpServletRequest request = new MockHttpServletRequest(verb, 
namespace + "/mark");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        Map<String, Object> ctx = new HashMap<>();
+        ctx.put(StrutsStatics.HTTP_REQUEST, request);
+        ctx.put(StrutsStatics.HTTP_RESPONSE, response);
+        
ctx.putAll(ActionContext.of(ctx).withParameters(org.apache.struts2.dispatcher.HttpParameters.create(new
 HashMap<String, Object>()).build()).getContextMap());
+        ActionProxy proxy = actionProxyFactory.createActionProxy(namespace, 
"mark", null, ctx);
+        proxy.execute();
+        return response;
+    }
+
+    private void assertGetRejected(String namespace) throws Exception {
+        MockHttpServletResponse response = invoke(namespace, "GET");
+        assertEquals(405, response.getStatus());
+        assertEquals("POST", response.getHeader("Allow"));
+        assertFalse("action must not run", PostOnlyAction.invoked);
+    }
+
+    public void testControlCoreDefaultStackRejectsGetOnPostOnlyAction() throws 
Exception {
+        assertGetRejected("/core");
+    }
+
+    public void testRestDefaultStackRejectsGetOnPostOnlyAction() throws 
Exception {
+        assertGetRejected("/rest");
+    }
+
+    public void testRestDefaultStackAllowsPostOnPostOnlyAction() throws 
Exception {
+        MockHttpServletResponse response = invoke("/rest", "POST");
+        assertEquals(200, response.getStatus());
+        assertNull(response.getHeader("Allow"));
+        assertTrue("action must run", PostOnlyAction.invoked);
+    }
+
+    public void testActualPutIsAllowedOnPutAnnotatedUpdate() throws Exception {
+        assertMappedRequest("native", "PUT", null, "update", 200, null, 1);
+    }
+
+    public void testActualDeleteIsAllowedOnDeleteAnnotatedDestroy() throws 
Exception {
+        assertMappedRequest("native", "DELETE", null, "destroy", 200, null, 1);
+    }
+
+    public void testTunneledPutIsRejectedOnPutAnnotatedUpdate() throws 
Exception {
+        assertMappedRequest("native", "POST", "put", "update", 405, "PUT", 0);
+    }
+
+    public void testTunneledDeleteIsRejectedOnDeleteAnnotatedDestroy() throws 
Exception {
+        assertMappedRequest("native", "POST", "delete", "destroy", 405, 
"DELETE", 0);
+    }
+
+    public void testTunneledPutIsAllowedOnPostAnnotatedUpdate() throws 
Exception {
+        assertMappedRequest("tunneled", "POST", "put", "update", 200, null, 1);
+    }
+
+    public void testTunneledDeleteIsAllowedOnPostAnnotatedDestroy() throws 
Exception {
+        assertMappedRequest("tunneled", "POST", "delete", "destroy", 200, 
null, 1);
+    }
+
+    public void testActualPutIsStillAllowedWhenUpdateAlsoAllowsPost() throws 
Exception {
+        assertMappedRequest("tunneled", "PUT", null, "update", 200, null, 1);
+    }
+
+    public void testActualDeleteIsStillAllowedWhenDestroyAlsoAllowsPost() 
throws Exception {
+        assertMappedRequest("tunneled", "DELETE", null, "destroy", 200, null, 
1);
+    }
+
+    private void assertMappedRequest(String actionName, String verb, String 
override, String actionMethod,
+                                     int status, String allow, int 
invocations) throws Exception {
+        String path = "/rest/" + actionName + "/1";
+        MockHttpServletRequest request = new MockHttpServletRequest(verb, 
path);
+        request.setServletPath(path);
+        if (override != null) {
+            request.setParameter(RestActionMapper.HTTP_METHOD_PARAM, override);
+        }
+        RestActionMapper mapper = new RestActionMapper();
+        container.inject(mapper);
+        ActionMapping mapping = mapper.getMapping(request, 
configurationManager);
+        assertNotNull(mapping);
+        assertEquals("/rest", mapping.getNamespace());
+        assertEquals(actionName, mapping.getName());
+        assertEquals(actionMethod, mapping.getMethod());
+
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        Map<String, Object> context = ActionContext.of()
+                .withServletRequest(request)
+                .withServletResponse(response)
+                .withActionMapping(mapping)
+                
.withParameters(HttpParameters.create(request.getParameterMap()).build())
+                .getContextMap();
+        ActionProxy proxy = 
actionProxyFactory.createActionProxy(mapping.getNamespace(), mapping.getName(),
+                mapping.getMethod(), context, false, true);
+        NativeVerbAction action = (NativeVerbAction) proxy.getAction();
+        proxy.execute();
+
+        assertEquals(status, response.getStatus());
+        assertEquals(allow, response.getHeader("Allow"));
+        assertEquals(invocations, action.invocations);
+        assertEquals(HttpMethod.parse(verb), action.method);
+    }
+
+    public static class NativeVerbAction extends ActionSupport implements 
HttpMethodAware {
+        private int invocations;
+        private HttpMethod method;
+
+        @HttpPut
+        public String update() {
+            invocations++;
+            return NONE;
+        }
+
+        @HttpDelete
+        public String destroy() {
+            invocations++;
+            return NONE;
+        }
+
+        @Override
+        public void setMethod(HttpMethod method) {
+            this.method = method;
+        }
+
+        @Override
+        public String getBadRequestResultName() {
+            return null;
+        }
+    }
+
+    public static class TunneledVerbAction extends NativeVerbAction {
+        @HttpPost
+        @HttpPut
+        @Override
+        public String update() {
+            return super.update();
+        }
+
+        @HttpPost
+        @HttpDelete
+        @Override
+        public String destroy() {
+            return super.destroy();
+        }
+    }
+}
diff --git a/plugins/rest/src/test/resources/ww-httpmethod-stack.xml 
b/plugins/rest/src/test/resources/ww-httpmethod-stack.xml
new file mode 100644
index 000000000..31672848e
--- /dev/null
+++ b/plugins/rest/src/test/resources/ww-httpmethod-stack.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+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.
+-->
+<!DOCTYPE struts PUBLIC
+        "-//Apache Software Foundation//DTD Struts Configuration 6.0//EN"
+        "https://struts.apache.org/dtds/struts-6.0.dtd";>
+<struts>
+    <package name="restPkg" namespace="/rest" extends="rest-default">
+        <action name="mark" class="org.apache.struts2.rest.PostOnlyAction"/>
+        <action name="native" 
class="org.apache.struts2.rest.RestStackHttpMethodEnforcementTest$NativeVerbAction"/>
+        <action name="tunneled" 
class="org.apache.struts2.rest.RestStackHttpMethodEnforcementTest$TunneledVerbAction"/>
+    </package>
+    <package name="corePkg" namespace="/core" extends="struts-default">
+        <action name="mark" class="org.apache.struts2.rest.PostOnlyAction"/>
+    </package>
+</struts>

Reply via email to