|
| 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