Skip to content

Commit fbcb374

Browse files
lukaszlenartclaude
andcommitted
fix(core): correct isMethodSpecified() for wildcard-resolved methods
DefaultActionProxy.resolveMethod() unconditionally set methodSpecified=false when the method was not passed explicitly, including when it was resolved from ActionConfig (e.g., wildcard substitution like method="{1}"). This caused HttpMethodInterceptor to skip method-level annotation checks for wildcard actions, falling back to class-level annotations instead. Move methodSpecified=false inside the inner branch that defaults to "execute", so config-resolved methods (including wildcard-substituted ones) correctly report isMethodSpecified()=true. Update Javadoc to reflect the corrected semantics. Fixes [WW-5535](https://issues.apache.org/jira/browse/WW-5535) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a21c763 commit fbcb374

7 files changed

Lines changed: 303 additions & 20 deletions

File tree

core/src/main/java/org/apache/struts2/ActionProxy.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,16 @@ public interface ActionProxy {
9393
String getMethod();
9494

9595
/**
96-
* Gets status of the method value's initialization.
96+
* Returns whether the action method was explicitly specified rather than defaulting to {@code "execute"}.
97+
* <p>
98+
* This returns {@code true} when the method was provided via the URL (DMI), passed as a constructor argument,
99+
* or resolved from the action configuration (including wildcard-substituted values like {@code method="{1}"}).
100+
* It returns {@code false} only when no method was specified anywhere and the framework fell back
101+
* to the default {@code "execute"} method.
102+
* </p>
97103
*
98-
* @return true if the method returned by getMethod() is not a default initializer value.
104+
* @return {@code true} if the method was explicitly provided or resolved from config;
105+
* {@code false} only when defaulting to {@code "execute"}
99106
*/
100107
boolean isMethodSpecified();
101108

core/src/main/java/org/apache/struts2/DefaultActionProxy.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,14 @@ public class DefaultActionProxy implements ActionProxy, Serializable {
7171
* <p>
7272
* The reason for the builder methods is so that you can use a subclass to create your own DefaultActionProxy instance
7373
* </p>
74-
*
74+
* <p>
7575
* (like a RMIActionProxy).
7676
*
77-
* @param inv the action invocation
78-
* @param namespace the namespace
79-
* @param actionName the action name
80-
* @param methodName the method name
81-
* @param executeResult execute result
77+
* @param inv the action invocation
78+
* @param namespace the namespace
79+
* @param actionName the action name
80+
* @param methodName the method name
81+
* @param executeResult execute result
8282
* @param cleanupContext cleanup context
8383
*/
8484
protected DefaultActionProxy(ActionInvocation inv, String namespace, String actionName, String methodName, boolean executeResult, boolean cleanupContext) {
@@ -171,8 +171,8 @@ private void resolveMethod() {
171171
this.method = config.getMethodName();
172172
if (StringUtils.isEmpty(this.method)) {
173173
this.method = ActionConfig.DEFAULT_METHOD;
174+
methodSpecified = false;
174175
}
175-
methodSpecified = false;
176176
}
177177
}
178178

core/src/test/java/org/apache/struts2/DefaultActionProxyTest.java

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,67 @@
1818
*/
1919
package org.apache.struts2;
2020

21-
import org.apache.struts2.mock.MockActionInvocation;
22-
import org.apache.struts2.StrutsInternalTestCase;
21+
import org.apache.struts2.config.ConfigurationException;
2322
import org.apache.struts2.config.StrutsXmlConfigurationProvider;
24-
import org.junit.Test;
23+
import org.apache.struts2.mock.MockActionInvocation;
2524

2625
public class DefaultActionProxyTest extends StrutsInternalTestCase {
2726

28-
@Test
29-
public void testThorwExceptionOnNotAllowedMethod() throws Exception {
30-
final String filename = "org/apache/struts2/config/providers/xwork-test-allowed-methods.xml";
31-
loadConfigurationProviders(new StrutsXmlConfigurationProvider(filename));
27+
private static final String CONFIG = "org/apache/struts2/config/providers/xwork-test-allowed-methods.xml";
28+
29+
public void testThrowExceptionOnNotAllowedMethod() {
30+
loadConfigurationProviders(new StrutsXmlConfigurationProvider(CONFIG));
3231
DefaultActionProxy dap = new DefaultActionProxy(new MockActionInvocation(), "strict", "Default", "notAllowed", true, true);
3332
container.inject(dap);
3433

3534
try {
3635
dap.prepare();
3736
fail("Must throw exception!");
38-
} catch (Exception e) {
39-
assertEquals(e.getMessage(), "Method notAllowed for action Default is not allowed!");
37+
} catch (ConfigurationException e) {
38+
assertEquals("Method notAllowed for action Default is not allowed!", e.getMessage());
4039
}
4140
}
41+
42+
public void testMethodSpecifiedWhenPassedExplicitly() {
43+
loadConfigurationProviders(new StrutsXmlConfigurationProvider(CONFIG));
44+
DefaultActionProxy dap = new DefaultActionProxy(new MockActionInvocation(), "default", "Default", "input", true, true);
45+
container.inject(dap);
46+
dap.prepare();
47+
48+
assertTrue("Method should be specified when passed as constructor argument", dap.isMethodSpecified());
49+
assertEquals("input", dap.getMethod());
50+
}
51+
52+
public void testMethodSpecifiedWhenResolvedFromConfig() {
53+
loadConfigurationProviders(new StrutsXmlConfigurationProvider(CONFIG));
54+
// ConfigMethod action has method="onPostOnly" in XML config, no method passed in constructor
55+
DefaultActionProxy dap = new DefaultActionProxy(new MockActionInvocation(), "default", "ConfigMethod", null, true, true);
56+
container.inject(dap);
57+
dap.prepare();
58+
59+
assertTrue("Method should be specified when resolved from action config", dap.isMethodSpecified());
60+
assertEquals("onPostOnly", dap.getMethod());
61+
}
62+
63+
public void testMethodNotSpecifiedWhenDefaultingToExecute() {
64+
loadConfigurationProviders(new StrutsXmlConfigurationProvider(CONFIG));
65+
// NoMethod action has no method in XML config and no method passed in constructor
66+
DefaultActionProxy dap = new DefaultActionProxy(new MockActionInvocation(), "default", "NoMethod", null, true, true);
67+
container.inject(dap);
68+
dap.prepare();
69+
70+
assertFalse("Method should not be specified when defaulting to execute", dap.isMethodSpecified());
71+
assertEquals("execute", dap.getMethod());
72+
}
73+
74+
public void testMethodSpecifiedWithWildcardAction() {
75+
loadConfigurationProviders(new StrutsXmlConfigurationProvider(CONFIG));
76+
// Wild-onPostOnly matches Wild-* with method="{1}" -> resolves to "onPostOnly"
77+
DefaultActionProxy dap = new DefaultActionProxy(new MockActionInvocation(), "default", "Wild-onPostOnly", null, true, true);
78+
container.inject(dap);
79+
dap.prepare();
80+
81+
assertTrue("Method should be specified when resolved from wildcard config", dap.isMethodSpecified());
82+
assertEquals("onPostOnly", dap.getMethod());
83+
}
4284
}

core/src/test/java/org/apache/struts2/config/providers/XmlConfigurationProviderAllowedMethodsTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public void testDefaultAllowedMethods() throws ConfigurationException {
4242
Map actionConfigs = pkg.getActionConfigs();
4343

4444
// assertions
45-
assertEquals(5, actionConfigs.size());
45+
assertEquals(8, actionConfigs.size());
4646

4747
ActionConfig action = (ActionConfig) actionConfigs.get("Default");
4848
assertEquals(1, action.getAllowedMethods().size());

core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodInterceptorTest.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,54 @@ public void testPostOnPutOrPostMethod() throws Exception {
217217
assertEquals(HttpMethod.POST, action.getHttpMethod());
218218
}
219219

220+
/**
221+
* Simulates a wildcard action like {@code <action name="Wild-*" method="{1}">}
222+
* resolving to method "onPostOnly" (annotated with @HttpPost).
223+
* With the fix in DefaultActionProxy.resolveMethod(), config-resolved methods
224+
* set isMethodSpecified()=true, so the interceptor checks method-level annotations.
225+
* A GET request should be rejected because @HttpPost only allows POST.
226+
*/
227+
public void testWildcardResolvedMethodWithPostAnnotationRejectsGet() throws Exception {
228+
// given
229+
HttpMethodsTestAction action = new HttpMethodsTestAction();
230+
prepareActionInvocation(action);
231+
// Simulate wildcard resolution: Wild-onPostOnly -> method="onPostOnly"
232+
actionProxy.setMethod("onPostOnly");
233+
// After the fix, config-resolved methods have methodSpecified=true
234+
actionProxy.setMethodSpecified(true);
235+
236+
invocation.setResultCode("onPostOnly");
237+
238+
prepareRequest("get");
239+
240+
// when
241+
String resultName = interceptor.intercept(invocation);
242+
243+
// then - interceptor checks method-level @HttpPost and rejects GET
244+
assertEquals("bad-request", resultName);
245+
}
246+
247+
/**
248+
* Counterpart: same wildcard scenario but with POST request — should succeed.
249+
*/
250+
public void testWildcardResolvedMethodWithPostAnnotationAllowsPost() throws Exception {
251+
// given
252+
HttpMethodsTestAction action = new HttpMethodsTestAction();
253+
prepareActionInvocation(action);
254+
actionProxy.setMethod("onPostOnly");
255+
actionProxy.setMethodSpecified(true);
256+
257+
invocation.setResultCode("onPostOnly");
258+
259+
prepareRequest("post");
260+
261+
// when
262+
String resultName = interceptor.intercept(invocation);
263+
264+
// then - interceptor checks method-level @HttpPost and allows POST
265+
assertEquals("onPostOnly", resultName);
266+
}
267+
220268
private void prepareActionInvocation(Object action) {
221269
interceptor = new HttpMethodInterceptor();
222270
invocation = new MockActionInvocation();

core/src/test/resources/org/apache/struts2/config/providers/xwork-test-allowed-methods.xml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
</action>
3030

3131
<action name="Boring">
32-
<allowed-methods> </allowed-methods>
32+
<allowed-methods></allowed-methods>
3333
</action>
3434

3535
<action name="Foo">
@@ -43,6 +43,18 @@
4343
<action name="Baz" method="baz">
4444
<allowed-methods>foo,bar</allowed-methods>
4545
</action>
46+
47+
<action name="Wild-*" class="org.apache.struts2.HttpMethodsTestAction" method="{1}">
48+
<allowed-methods>regex:.*</allowed-methods>
49+
</action>
50+
51+
<action name="ConfigMethod" class="org.apache.struts2.HttpMethodsTestAction" method="onPostOnly">
52+
<allowed-methods>regex:.*</allowed-methods>
53+
</action>
54+
55+
<action name="NoMethod" class="org.apache.struts2.HttpMethodsTestAction">
56+
<allowed-methods>regex:.*</allowed-methods>
57+
</action>
4658
</package>
4759

4860
<package name="strict" strict-method-invocation="true">
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
---
2+
date: 2026-02-22T12:00:00+01:00
3+
topic: "HttpMethodInterceptor does not work with action names using wildcards"
4+
tags: [research, codebase, HttpMethodInterceptor, DefaultActionProxy, wildcard, isMethodSpecified, security]
5+
status: complete
6+
git_commit: a21c763d8a8592f1056086134414123f6d8d168d
7+
---
8+
9+
# Research: WW-5535 — HttpMethodInterceptor does not work with wildcard action names
10+
11+
**Date**: 2026-02-22
12+
13+
## Research Question
14+
15+
Why does `HttpMethodInterceptor` fail to enforce HTTP method validation when actions use wildcard names like
16+
`example-*`?
17+
18+
## Summary
19+
20+
The root cause is in `DefaultActionProxy.resolveMethod()`. For wildcard-matched actions, the method name is resolved
21+
from the `ActionConfig` (after wildcard substitution) rather than being passed explicitly from the URL. Because
22+
`resolveMethod()` treats any method resolved from config as "not specified", `isMethodSpecified()` returns `false`. This
23+
causes `HttpMethodInterceptor` to check **class-level** annotations instead of **method-level** annotations, potentially
24+
skipping validation entirely.
25+
26+
## Detailed Findings
27+
28+
### 1. The `methodSpecified` Flag Logic
29+
30+
**File**: [
31+
`DefaultActionProxy.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/main/java/org/apache/struts2/DefaultActionProxy.java)
32+
33+
```java
34+
private boolean methodSpecified = true; // field default
35+
36+
private void resolveMethod() {
37+
if (StringUtils.isEmpty(this.method)) {
38+
this.method = config.getMethodName();
39+
if (StringUtils.isEmpty(this.method)) {
40+
this.method = ActionConfig.DEFAULT_METHOD;
41+
}
42+
methodSpecified = false; // <-- ONLY path that sets it false
43+
}
44+
}
45+
```
46+
47+
The flag is set to `false` whenever the method was not passed explicitly to the proxy constructor. This conflates two
48+
different concepts:
49+
50+
- **"Was the method specified by the user via DMI?"** (security concern — user-controlled method invocation)
51+
- **"Is a non-default method being invoked?"** (what `HttpMethodInterceptor` needs to know)
52+
53+
### 2. Wildcard Resolution Flow
54+
55+
For a config like `<action name="example-*" class="ExampleAction" method="do{1}">` and URL `example-create`:
56+
57+
| Step | Component | Result |
58+
|------|----------------------------------------------|-------------------------------------------------------------------------------------------------------------|
59+
| 1 | `DefaultActionMapper.extractMethodName()` | `ActionMapping.method = null` (exact map lookup fails for wildcards) |
60+
| 2 | `Dispatcher.serviceAction()` | Passes `null` method to `ActionProxyFactory` |
61+
| 3 | `DefaultActionProxy` constructor | `this.method = null` |
62+
| 4 | `RuntimeConfigurationImpl.getActionConfig()` | Triggers `ActionConfigMatcher.match()``convert()` substitutes `{1}``config.methodName = "docreate"` |
63+
| 5 | `DefaultActionProxy.resolveMethod()` | `this.method` is empty → takes `config.getMethodName()` = `"docreate"`**sets `methodSpecified = false`** |
64+
65+
**Key file**: [
66+
`ActionConfigMatcher.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/main/java/org/apache/struts2/config/impl/ActionConfigMatcher.java)
67+
`convert()` performs `{n}` substitution on method name.
68+
69+
**Key file**: [
70+
`DefaultActionMapper.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/main/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.java)
71+
`extractMethodName()` uses `cfg.getActionConfigs().get(mapping.getName())` which is an exact map lookup that cannot
72+
match wildcard patterns.
73+
74+
### 3. HttpMethodInterceptor Behavior
75+
76+
**File**: [
77+
`HttpMethodInterceptor.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/main/java/org/apache/struts2/interceptor/httpmethod/HttpMethodInterceptor.java)
78+
79+
```java
80+
if (invocation.getProxy().isMethodSpecified()) {
81+
// Check METHOD-LEVEL annotations (e.g., @HttpGet on the specific method)
82+
Method method = action.getClass().getMethod(invocation.getProxy().getMethod());
83+
if (AnnotationUtils.isAnnotatedBy(method, HTTP_METHOD_ANNOTATIONS)) {
84+
return doIntercept(invocation, method);
85+
}
86+
} else if (AnnotationUtils.isAnnotatedBy(action.getClass(), HTTP_METHOD_ANNOTATIONS)) {
87+
// Check CLASS-LEVEL annotations only
88+
return doIntercept(invocation, action.getClass());
89+
}
90+
// No annotations → allow request through (no validation)
91+
```
92+
93+
**Impact for wildcard actions**: Since `isMethodSpecified()` returns `false`, the interceptor checks class-level
94+
annotations. If the action class has no class-level HTTP method annotations (only method-level ones), validation is *
95+
*skipped entirely**.
96+
97+
### 4. The Semantic Mismatch
98+
99+
The `ActionProxy.isMethodSpecified()` Javadoc says:
100+
101+
> Returns true if the method returned by `getMethod()` is not a default initializer value.
102+
103+
The current implementation interprets "default initializer" as "anything not explicitly passed from the URL/DMI", which
104+
includes wildcard-configured methods. But for `HttpMethodInterceptor`, what matters is whether a *specific* method (not
105+
`execute`) is being invoked, regardless of how it was determined.
106+
107+
## Code References
108+
109+
- [
110+
`DefaultActionProxy.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/main/java/org/apache/struts2/DefaultActionProxy.java)
111+
`resolveMethod()` and `methodSpecified` field
112+
- [
113+
`ActionProxy.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/main/java/org/apache/struts2/ActionProxy.java)
114+
`isMethodSpecified()` interface and Javadoc
115+
- [
116+
`HttpMethodInterceptor.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/main/java/org/apache/struts2/interceptor/httpmethod/HttpMethodInterceptor.java)
117+
`intercept()` branching on `isMethodSpecified()`
118+
- [
119+
`ActionConfigMatcher.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/main/java/org/apache/struts2/config/impl/ActionConfigMatcher.java)
120+
wildcard `{n}` substitution in `convert()`
121+
- [
122+
`DefaultActionMapper.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/main/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.java)
123+
`extractMethodName()` exact-match lookup
124+
- [
125+
`DefaultConfiguration.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/main/java/org/apache/struts2/config/impl/DefaultConfiguration.java)
126+
`findActionConfigInNamespace()` wildcard fallback
127+
- [
128+
`HttpMethodInterceptorTest.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/test/java/org/apache/struts2/interceptor/httpmethod/HttpMethodInterceptorTest.java)
129+
existing tests
130+
- [
131+
`DefaultActionProxyTest.java`](https://github.com/apache/struts/blob/a21c763d8a8592f1056086134414123f6d8d168d/core/src/test/java/org/apache/struts2/DefaultActionProxyTest.java)
132+
only tests disallowed method, not `isMethodSpecified()`
133+
134+
## Architecture Insights
135+
136+
The `isMethodSpecified()` flag was originally introduced (WW-3628) to distinguish user-controlled DMI method invocation
137+
from default `execute()` dispatch. This was a security measure — DMI-specified methods need stricter validation.
138+
139+
However, the flag now serves double duty:
140+
141+
1. **Security gate** in DMI handling — was the method user-specified via URL?
142+
2. **Dispatch hint** in `HttpMethodInterceptor` — should we check method-level or class-level annotations?
143+
144+
These two concerns have different semantics for wildcard actions. A wildcard-configured method like `do{1}` is **not
145+
user-controlled** (it's defined in the config), but it **is a specific method** that should have its annotations
146+
checked.
147+
148+
## Potential Fix Approaches
149+
150+
1. **Fix in `HttpMethodInterceptor`**: Instead of relying on `isMethodSpecified()`, check method-level annotations
151+
first, then fall back to class-level. This avoids changing the `ActionProxy` contract.
152+
153+
2. **Fix in `DefaultActionProxy.resolveMethod()`**: Set `methodSpecified = true` when the method is resolved from
154+
config (not just defaulted to `execute`). This changes the semantics of the flag but aligns with the Javadoc ("not a
155+
default initializer value").
156+
157+
3. **Add a new flag**: Introduce `isMethodFromConfig()` or similar to distinguish "method from wildcard config" from "
158+
method from DMI" from "default execute". This is the most precise but highest-impact change.
159+
160+
## Test Gaps
161+
162+
- No tests for `DefaultActionProxy` with wildcard-resolved action names
163+
- No tests for `isMethodSpecified()` on a real `DefaultActionProxy` instance (all tests use `MockActionProxy`)
164+
- No tests for `HttpMethodInterceptor` combined with wildcard-resolved methods
165+
166+
## Historical Context (from thoughts/)
167+
168+
No prior research documents found for WW-5535 or WW-3628.
169+
170+
## Open Questions
171+
172+
1. Are there other interceptors or components that depend on `isMethodSpecified()` semantics?
173+
2. Would changing `methodSpecified` behavior for config-resolved methods break DMI security checks?
174+
3. Should `DefaultActionMapper.extractMethodName()` be updated to also resolve wildcard-matched configs?

0 commit comments

Comments
 (0)