[ 
https://issues.apache.org/jira/browse/WW-5659?focusedWorklogId=1032372&page=com.atlassian.jira.plugin.system.issuetabpanels:worklog-tabpanel#worklog-1032372
 ]

ASF GitHub Bot logged work on WW-5659:
--------------------------------------

                Author: ASF GitHub Bot
            Created on: 27/Jul/26 10:19
            Start Date: 27/Jul/26 10:19
    Worklog Time Spent: 10m 
      Work Description: lukaszlenart opened a new pull request, #1816:
URL: https://github.com/apache/struts/pull/1816

   Fixes [WW-5659](https://issues.apache.org/jira/browse/WW-5659)
   
   Supersedes #1815, which reported the problem. The concurrency scenario and 
test harness come from @deprrous's work there and are carried over with 
attribution.
   
   ## Problem
   
   `WithLazyParams.LazyParamInjector#injectParams` resolved `${...}` 
interceptor params once per request and wrote the resolved values straight onto 
the interceptor:
   
   ```java
   ognlUtil.setProperty(entry.getKey(), paramValue, interceptor, 
invocationContext.getContextMap());
   ```
   
   That interceptor is a singleton, built once at configuration-parse time and 
reused for every request. When an action references a stack without overriding 
params, `InterceptorBuilder` hands the same `InterceptorMapping` objects to 
every such action, so the instance is shared across actions too. Request-scoped 
state was being written to process-wide state with no synchronisation.
   
   For `ActionFileUploadInterceptor` — the only implementer — the resolved 
policy landed in plain instance fields that `acceptFile` read back later. 
Nothing guarded the interval, so two concurrent requests resolving different 
policies could have their `allowedTypes`, `allowedExtensions` and `maximumSize` 
cross over. `disabled` was affected the same way, and it decides whether the 
interceptor runs at all.
   
   `DefaultActionInvocation` also called `params.putAll(...)` on the live map 
returned by `InterceptorMapping#getParams` — an unsynchronised write to a 
shared `HashMap` on every request.
   
   **Scope:** only applications opting into the dynamic `${...}` form added in 
WW-5585 (7.2.0) are affected. `struts-default.xml` declares `actionFileUpload` 
with no params, so a default configuration resolves nothing and writes nothing. 
Static params resolve to the same value every request. This was assessed as a 
thread-safety defect rather than a framework vulnerability — `actionFileUpload` 
runs ahead of `staticParams`/`params` in `defaultStack`, so no request 
parameter is bound to the action at resolution time and the expression's 
*value* is not attacker-supplied.
   
   ## Approach
   
   Fix the contract rather than its one implementer, so no future implementer 
can reintroduce the bug. Resolved params are written into a per-invocation 
object the interceptor supplies and receives back; the interceptor stays 
immutable after `init()`.
   
   ```java
   public interface WithLazyParams<P extends InterceptorParams> {
       P newLazyParams();
       String intercept(ActionInvocation invocation, P lazyParams) throws 
Exception;
   }
   ```
   
   New types in `org.apache.struts2.interceptor`:
   
   - `InterceptorParams` — the general contract and the generic bound. One 
defaulted method, `unresolved(String)`.
   - `DisableParams` — opt-in support for the `disabled` param. `disabled` is 
universal across interceptors while lazy resolution is rare, so it is not a 
subtype of any lazy-specific type.
   - `UploadPolicy extends DisableParams` — the upload holder, with typed 
setters so OGNL still converts `String` → `Long` for `maximumSize`.
   
   `LazyParamInjector.injectParams` becomes `resolveInto(P target, ...)`. 
`DefaultActionInvocation` merges the lazy and conditional dispatch paths, and 
`mergedParams` returns a fresh map.
   
   There is no cleanup step — no `finally`, nothing to clear, no `ThreadLocal`. 
That absence is the design's own check: if a future change makes cleanup 
necessary, the separation has regressed.
   
   Rejected alternatives, with reasoning, are in the design doc — including the 
`ThreadLocal` approach from #1815, which leaves the unsafe write path in place 
rather than removing it.
   
   ## Fail-closed
   
   Previously an expression that failed to resolve yielded `""`, which became 
an empty set, which `acceptFile` read as "no restriction" — so a typo or a null 
intermediate silently switched off an upload restriction. Now any param that 
cannot be applied marks the policy unusable and the upload is rejected with 
`struts.messages.error.upload.policy.unresolved`.
   
   This is a behaviour change for 7.2.x applications with a broken expression. 
Those applications are currently running with that validation silently 
disabled, which is the reason to surface it — **worth calling out in the 
release notes.**
   
   ## Breaking changes for 7.3.0
   
   `WithLazyParams` is public API since 2.5.9, but 
`ActionFileUploadInterceptor` is its only implementer in the repo, so 
third-party implementers get a compile error rather than silent breakage:
   
   - `WithLazyParams` is now generic and declares `newLazyParams()` plus a 
two-argument `intercept`. `injectParams` is gone.
   - `AbstractFileUploadInterceptor.acceptFile` gained a leading `UploadPolicy` 
parameter (`protected`).
   - A lazily resolved `disabled` only takes effect if the holder extends 
`DisableParams`. There is deliberately no fallback to the interceptor instance 
— that is the racy path being removed.
   - An interceptor overriding `shouldIntercept` to read its own 
lazily-injected fields now sees config-time values only, since resolution no 
longer touches the interceptor. `ActionFileUploadInterceptor` does not override 
it, so nothing shipped is affected.
   
   ## Two open questions for review
   
   Draft because these are worth a second opinion before merge:
   
   1. **Unknown params now fail closed too.** Fixing a fail-open path meant 
notifying the holder whenever a write is skipped, and that catch also covers 
`NoSuchPropertyException` — `OgnlUtil.internalSetProperty` wraps every 
`OgnlException` into the same `ReflectionException`, so the cases cannot be 
separated at the call site. A typo'd param on the lazy path therefore rejects 
uploads instead of being silently ignored. `UploadPolicy` exposes exactly the 
four legitimate params, so anything else is definitionally a misconfiguration — 
but it is a widening worth agreeing on.
   
   2. **`executeConditional` overload.** Getting the mapping name into the log 
needed a two-argument form; the one-argument version is kept `@Deprecated` and 
delegating. There are no current callers of the old form, and this branch 
already breaks `protected acceptFile`, so collapsing to a single method would 
be equally safe and leaner. Happy to do that if preferred.
   
   ## Follow-ups, not in this PR
   
   - `DefaultActionInvocation.mergedParams` looks up its own mapping by name, 
so the second `putAll` is a no-op for normal refs and merges the *wrong* params 
for a stack referencing one interceptor twice. Inherited from the pre-existing 
code; recorded in a comment, ticket to follow.
   - `isUnresolved` infers failure from an empty string because 
`OgnlTextParser` discards the distinction between "did not resolve" and 
"resolved to empty". Threading a real signal out of the parser would also close 
the partial-resolution blind spot (`${a},${b}` with only `${b}` failing writes 
a truncated value).
   
   ## Testing
   
   `mvn test -DskipAssembly` — 28 modules, 4220 tests, 0 failures.
   
   The concurrency regression is deterministic rather than timing-dependent: 
the first thread is parked inside `acceptFile` on a latch and the second is 
only submitted once that is confirmed. Reverting `copyConfiguredPolicy()` to 
return the shared instance makes it fail exactly as the bug describes — the 
`text/plain` invocation accepts a `text/html` upload. Both skip branches in the 
merged dispatch path are pinned one-to-one by dedicated fixtures.
   




Issue Time Tracking
-------------------

            Worklog Id:     (was: 1032372)
    Remaining Estimate: 0h
            Time Spent: 10m

> WithLazyParams resolves dynamic interceptor params onto the shared 
> interceptor instance
> ---------------------------------------------------------------------------------------
>
>                 Key: WW-5659
>                 URL: https://issues.apache.org/jira/browse/WW-5659
>             Project: Struts 2
>          Issue Type: Bug
>          Components: Core Interceptors
>            Reporter: Lukasz Lenart
>            Priority: Major
>             Fix For: 7.3.0
>
>          Time Spent: 10m
>  Remaining Estimate: 0h
>
> h3. Problem
> {{WithLazyParams.LazyParamInjector#injectParams}} resolves {{${...}}} 
> interceptor
> parameters per request and writes the resolved values straight onto the 
> interceptor
> instance:
> {code:java}
> // WithLazyParams.java:78-84
> public Interceptor injectParams(Interceptor interceptor, Map<String, String> 
> params, ActionContext invocationContext) {
>     for (Map.Entry<String, String> entry : params.entrySet()) {
>         Object paramValue = textParser.evaluate(new char[]{'$'}, 
> entry.getValue(), valueEvaluator, TextParser.DEFAULT_LOOP_COUNT);
>         ognlUtil.setProperty(entry.getKey(), paramValue, interceptor, 
> invocationContext.getContextMap());
>     }
>     return interceptor;
> }
> {code}
> That interceptor is a singleton. It is built once at configuration-parse time
> ({{InterceptorBuilder}}:73-74 and :175-177) and reused for every request. 
> When an action
> references an interceptor stack without overriding params, 
> {{InterceptorBuilder}}:79
> (_result.addAll(stackConfig.getInterceptors())_) hands out the same 
> {{InterceptorMapping}}
> objects, so the same interceptor instance is shared across actions as well.
> The result is that request-scoped state is written to process-wide state with 
> no
> synchronisation.
> h3. Consequence for file upload validation
> {{ActionFileUploadInterceptor}} is currently the only {{WithLazyParams}} 
> implementer. Its
> resolved policy lands in plain instance fields
> ({{AbstractFileUploadInterceptor}}:62-64, written at :85, :94, :103) and is 
> read back
> later by {{acceptFile}} ({{AbstractFileUploadInterceptor}}:134, :141, :148).
> Per request the order is:
> * {{DefaultActionInvocation}}:269 - resolve and write the shared fields
> * {{DefaultActionInvocation}}:275 - {{intercept()}} -> {{acceptFile()}} reads 
> the shared fields
> Nothing guards the interval between the two. Two concurrent requests that 
> resolve
> different policies can therefore have their {{allowedTypes}}, 
> {{allowedExtensions}} and
> {{maximumSize}} cross over, and a request can be validated against another 
> request's
> upload policy.
> This only affects applications that opt into the dynamic {{${...}}} form 
> added in
> WW-5585. {{struts-default.xml}}:60 declares {{actionFileUpload}} with no 
> params, so the
> default configuration writes nothing and is unaffected. Static 
> (non-expression) params
> resolve to the same value on every request and are likewise unaffected.
> Note that {{actionFileUpload}} sits at position 15 of {{defaultStack}}, ahead 
> of
> {{staticParams}} (19), {{actionMappingParams}} (20) and {{params}} (21), so 
> no request
> parameter has been bound to the action at resolution time. The expression's 
> *value* is
> not attacker-supplied. Which policy gets selected can still depend on 
> request-derived
> state populated by {{prepare}} (11), {{scopedModelDriven}} (13) or 
> {{modelDriven}} (14) -
> the shipped showcase does exactly that in
> {{DynamicFileUploadAction#prepareUpload}}:136-139.
> h3. Secondary defect
> {{DefaultActionInvocation}}:262-267 calls {{params.putAll(...)}} on the map 
> returned by
> {{InterceptorMapping#getParams}}:60-62, which is the live shared map. That is 
> an
> unsynchronised write to a shared {{HashMap}} on every request.
> h3. Proposed fix
> Fix the {{WithLazyParams}} contract rather than patching the one implementer, 
> so that no
> future implementer can reintroduce the same bug. Resolved parameters should 
> be written
> into a per-invocation object supplied by the interceptor and passed back to 
> it, leaving
> the interceptor singleton immutable after {{init()}}:
> {code:java}
> public interface WithLazyParams<P> {
>     P newLazyParams();
>     String intercept(ActionInvocation invocation, P lazyParams) throws 
> Exception;
> }
> {code}
> Targeting a clean interface change in the next minor release. A design 
> document will
> follow.
> h3. References
> * WW-5585 - introduced dynamic parameter evaluation for file upload validation
> * GitHub PR [#1815|https://github.com/apache/struts/pull/1815] - reported the 
> problem; approach not adopted



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to