Skip to content

WW-5659 Resolve lazy interceptor params per invocation - #1816

Open
lukaszlenart wants to merge 23 commits into
mainfrom
WW-5659-lazy-params-request-scoping
Open

WW-5659 Resolve lazy interceptor params per invocation#1816
lukaszlenart wants to merge 23 commits into
mainfrom
WW-5659-lazy-params-request-scoping

Conversation

@lukaszlenart

@lukaszlenart lukaszlenart commented Jul 27, 2026

Copy link
Copy Markdown
Member

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

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. 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().

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.
  • 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 StringLong 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.

⚠️ Release notes / migration for 7.3.0

Four user-visible behaviour changes. Please pick these up when preparing the release announcement and docs.

1. An unresolvable ${...} upload param now rejects the upload instead of silently disabling validation.
Previously an expression that failed to resolve produced "", which became an empty set, which acceptFile read as "no restriction". Applications running with a broken expression have had that validation silently switched off; they will now see uploads rejected with struts.messages.error.upload.policy.unresolved. This is the change most likely to surprise on upgrade, and surfacing it is the point.

2. An unknown or misspelled lazy interceptor param now fails application startup.
Param names are known at configuration-parse time, so they are validated there and a bad one raises ConfigurationException rather than being ignored. An application carrying a latent typo in struts.xml that appeared to work will no longer boot until it is corrected.

3. A WithLazyParams holder must extend DisableParams to accept the disabled param.
There is deliberately no fallback to the interceptor instance — that fallback is the racy path being removed. A <param name="disabled"> on an interceptor whose holder does not extend DisableParams now fails startup. Third-party implementers must touch their code regardless, since the interface gained a type parameter.

4. newLazyParams() is now called once at configuration time, to discover the holder type for OGNL allowlist registration. The contract expects it to be side-effect free; an implementation that throws will now fail at startup rather than per request.

Breaking changes

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.
  • InterceptorParams extends Serializable, because Interceptor is serializable and an interceptor holds its configured params as a field.
  • AbstractFileUploadInterceptor.acceptFile gained a leading UploadPolicy parameter (protected).
  • DefaultActionInvocation.executeConditional(ConditionalInterceptor) was replaced by a two-argument form carrying the interceptor name. The single-argument version had no callers.
  • 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.

Follow-ups, not in this PR

Tickets to be filed separately:

  • Core tests run with the OGNL allowlist disabled, so a whole class of bug is invisible to them — an allowlist regression here reached CI on every job and was caught only by the showcase integration tests. An allowlist-enabled test slice would close that gap.
  • 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 pre-existing code; recorded in a comment.
  • isUnresolved infers failure from an empty string because OgnlTextParser discards the distinction between "did not resolve" and "resolved to empty". A real signal would also close the partial-resolution blind spot (${a},${b} with only ${b} failing writes a truncated value).
  • The configuration-time ConfigurationException points at the <interceptor> definition rather than the offending <interceptor-ref>, because the InterceptorFactory SPI has no parameter for the ref's Location. The message is actionable; the file:line is misleading.
  • DefaultActionInvocation is flagged by SonarCloud as a "Monster Class" (22 dependencies vs 20). Pre-existing; this branch added two imports and crossed the threshold.

Testing

mvn test -DskipAssembly — 28 modules, core 3104 tests, 0 failures. Showcase integration tests including DynamicFileUploadTest pass.

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, and LazyParamsAllowlistTest covers resolution with the OGNL allowlist enabled.

lukaszlenart and others added 13 commits July 27, 2026 10:01
WithLazyParams#injectParams resolves ${...} params onto the interceptor
singleton, so concurrent requests can read one another's resolved values.
For ActionFileUploadInterceptor that means allowedTypes, allowedExtensions,
maximumSize and disabled can cross between requests.

Design fixes the contract rather than the one implementer: resolved params
go into a per-invocation holder the interceptor supplies and receives back,
leaving the singleton immutable after init(). Adds InterceptorParams as the
general contract with DisableParams as opt-in support for the disabled param,
and makes unresolvable expressions fail closed instead of silently disabling
validation.

Reported via GitHub PR #1815; that approach (ThreadLocal on the interceptor)
is not adopted.

Co-Authored-By: Claude Opus 5 <[email protected]>
Six tasks, each independently testable and compiling: new InterceptorParams
and DisableParams types, LazyParamInjector.resolveInto alongside the old
path, a pure refactor onto a single UploadPolicy value object, the contract
switch plus DefaultActionInvocation wiring, fail-closed handling, and test
migration.

Records one deviation from the spec: the unresolved-param rule is applied
unconditionally rather than by introspecting the seeded value, which is not
implementable deterministically for the Long-typed maximumSize. Task 6
updates the spec to match.

Co-Authored-By: Claude Opus 5 <[email protected]>
…fail-closed behavior

isUnresolved cannot distinguish a failed ${...} resolution from an expression that
legitimately evaluates to an empty string; the parser gives no other signal. The
previous javadoc wrongly claimed the raw template let it tell the two apart. Fix
the javadoc to state the actual, intentional rule (fail-closed: treat both as
unusable), and add a test pinning that a legitimately-empty expression is treated
as unresolved rather than written.
Introduce UploadPolicy (extends DisableParams) to consolidate the three
loose maximumSize/allowedTypes/allowedExtensions fields on
AbstractFileUploadInterceptor into a single config-time value object.
acceptFile now takes the effective policy as an explicit parameter
instead of reading interceptor-level state directly.

Pure refactor, no behaviour change: the existing setters still mutate
the shared singleton via configuredPolicy, and ActionFileUploadInterceptor
copies it once per invocation via copyConfiguredPolicy() before calling
acceptFile. This groundwork lets a later change route lazily-resolved
per-request params into the copy instead of the singleton.
…cation disabled

The two skip branches in DefaultActionInvocation#invokeWithLazyParams had no
coverage: deleting either left the whole suite green. The only tests reaching
that method used LazyFoo/LazyFooWithStackParams, which declare no disabled
param, and MockLazyParams did not extend DisableParams, so the holder branch
was unreachable and shouldIntercept was always true.

Make MockLazyParams extend DisableParams and add two action configs that
isolate one branch each:

- LazyFooLazilyDisabled passes disabled as an interceptor-ref param, so it
  reaches InterceptorMapping#getParams(), resolves onto the holder, and
  exercises the DisableParams branch.
- LazyFooStaticallyDisabled sets disabled on the interceptor definition
  instead. InterceptorBuilder only puts interceptor-ref params into the
  mapping, so the holder never sees it and it can only be honoured through
  ConditionalInterceptor#shouldIntercept.

Verified by deleting each branch in turn: each deletion fails exactly the one
test that targets it, and no other.

Also make testDisabledIsResolvedPerInvocation earn its name. It previously
asserted only that newLazyParams() returns a fresh object, never resolving
anything, and built a MyDynamicFileUploadAction it never used. It now routes
two actions through a real LazyParamInjector#resolveInto of a
disabled=${uploadDisabled} param and pins that one invocation's resolved flag
survives the other's, and that neither reaches the interceptor singleton.
…pplied

resolveInto had two failure branches behaving oppositely. An unresolvable
${...} skipped the write and notified the holder, so a fail-closed holder such
as UploadPolicy could reject the upload. A value the holder's setter could not
accept — a non-numeric String for the Long maximumSize, say — also skipped the
write but notified nothing, leaving the policy reporting isUnresolved() == false
and maximumSize null, which acceptFile reads as "no size limit". The cap was
silently off and the file accepted: fail-open, through a branch the design never
enumerated.

Notify the holder from the catch block too, and record in the spec the general
rule that every path skipping a write must notify, so a future failure mode gets
checked against it.

Co-Authored-By: Claude Opus 5 <[email protected]>
…dispatch

UploadPolicy handed out the mutable HashSet built by commaDelimitedStringToSet,
which the copy constructor shares by reference with the configured policy, so a
subclass overriding the protected acceptFile could have rewritten process-wide
config from a request thread. The sets are unmodifiable now.

Also document that unresolved() records `disabled` like any other param, so an
unresolvable disabled expression leaves the interceptor enabled and rejects
every upload; and in DefaultActionInvocation use normal imports for the params
types, make the interceptor local final, word the three skip logs consistently
and identify the interceptor by its mapping name, and note that the name-based
param merge is inherited behaviour whose duplicate-ref handling is questionable.

Co-Authored-By: Claude Opus 5 <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a concurrency/thread-safety defect in Struts’ lazy interceptor parameter resolution (${...}) by changing the WithLazyParams contract so request-scoped resolved values are written into a per-invocation params holder instead of being applied to the shared interceptor singleton. It also updates file upload validation to use a per-invocation UploadPolicy (and fail closed when lazy params can’t be resolved), and adds regression tests for concurrency, disabled, and the new fail-closed behavior.

Changes:

  • Redesign WithLazyParams to resolve lazy params into a per-invocation InterceptorParams holder and invoke a new two-argument intercept(invocation, params) API.
  • Refactor file upload validation to use a per-invocation UploadPolicy copied from configured values; reject uploads when any required lazy param is unresolved.
  • Add/extend tests and test fixtures covering concurrency isolation, lazy disabled behavior, and unresolved/unknown param handling; update test XML configs accordingly.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
docs/superpowers/specs/2026-07-27-WW-5659-lazy-params-request-scoping-design.md Design write-up for request-scoped lazy param resolution and fail-closed semantics.
docs/superpowers/plans/2026-07-27-WW-5659-lazy-params-request-scoping.md Implementation plan documenting tasks, constraints, and intended behavior.
core/src/main/java/org/apache/struts2/interceptor/InterceptorParams.java Adds params-holder contract with unresolved(String) hook.
core/src/main/java/org/apache/struts2/interceptor/DisableParams.java Adds opt-in per-invocation disabled support for lazy params holders.
core/src/main/java/org/apache/struts2/interceptor/UploadPolicy.java Introduces per-invocation upload policy holder with unresolved tracking.
core/src/main/java/org/apache/struts2/interceptor/WithLazyParams.java Breaks/updates WithLazyParams API; adds holder-based resolution (resolveInto).
core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java Refactors upload validation to accept an UploadPolicy and fail closed on unresolved policy.
core/src/main/java/org/apache/struts2/interceptor/ActionFileUploadInterceptor.java Implements new WithLazyParams<UploadPolicy> contract; uses per-invocation policy during validation.
core/src/main/java/org/apache/struts2/DefaultActionInvocation.java Routes WithLazyParams interceptors through new holder-based path; avoids mutating shared param maps; adds executeConditional overload.
core/src/main/resources/org/apache/struts2/struts-messages.properties Adds new message key for unresolved upload policy rejection.
core/src/test/java/org/apache/struts2/interceptor/DisableParamsTest.java Unit tests for DisableParams.
core/src/test/java/org/apache/struts2/interceptor/LazyParamInjectorTest.java Tests new resolveInto behavior, including unresolved/unknown param notifications and type conversion.
core/src/test/java/org/apache/struts2/interceptor/ActionFileUploadInterceptorTest.java Updates tests for new policy/contract; adds concurrency and fail-closed regressions and helper harness.
core/src/test/java/org/apache/struts2/DefaultActionInvocationTest.java Adds regressions ensuring lazy/static disabled is respected on the lazy path.
core/src/test/java/org/apache/struts2/mock/MockLazyInterceptor.java Updates mock interceptor to new generic WithLazyParams contract and per-invocation params holder.
core/src/test/resources/xwork-test-default.xml Adds statically disabled lazy interceptor definition for test coverage.
core/src/test/resources/xwork-sample.xml Adds actions exercising lazy-resolved vs statically configured disabled behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread core/src/main/java/org/apache/struts2/interceptor/WithLazyParams.java Outdated
lukaszlenart and others added 10 commits July 27, 2026 12:47
…ccess

Moving lazy param resolution off the interceptor and onto a per-invocation
InterceptorParams holder changed the OGNL target of the write. The interceptor
is allowlisted at configuration time by XmlDocConfigurationProvider, because it
is named in the configuration; the holder is named nowhere, so with the shipped
default struts.allowlist.enable=true SecurityMemberAccess refused every setter,
resolveInto's fail-closed handling marked every param unresolved, and
ActionFileUploadInterceptor rejected every upload.

Register the holder's own class hierarchy through ProviderAllowlist when the
interceptor is built, keyed by the holder class so repeated builds collapse onto
one entry. Only the holder's class, superclasses and interfaces are registered -
the setter may be declared on any of them and SecurityMemberAccess checks both
the target and the declaring class. Object is filtered out: it says nothing
about the holder and is excluded by default anyway. No package is allowlisted.

Also run Interceptor#init() before the holder is obtained, so newLazyParams()
sees a fully initialised interceptor.

Co-Authored-By: Claude Opus 5 <[email protected]>
… enabled

Every other core test runs with struts.allowlist.enable=false - StrutsTestCaseHelper
turns it off by default and XWorkTestCaseHelper never loads default.properties -
so no core test could see the holder being blocked by SecurityMemberAccess. Only
the showcase DynamicFileUploadTest integration test exercised the production
setting, which is why the regression reached CI.

This test boots the dispatcher with the allowlist enforced and asserts both that
the holder hierarchy is registered at configuration time and that a ${...} param
actually lands on the policy rather than being reported unresolved. Reverting the
registration in DefaultInterceptorFactory fails it with the same
"Declaring class [UploadPolicy] ... is not allowlisted" warning seen in the
showcase failure.

Co-Authored-By: Claude Opus 5 <[email protected]>
…he upload policy

UploadPolicy#unresolved recorded every param name, disabled included, so an
unresolvable <param name="disabled">${...}</param> marked the whole policy
unusable and rejected every upload of the invocation. That is not a safe
default: disabled is not a validation dimension. Its unresolved value is simply
false, which leaves the interceptor running and the rest of the policy intact,
so it cannot relax validation - recording it only invents a second failure mode.

Exclude it from the tracking that gates isUnresolved(), via a new
DisableParams#DISABLED_PARAM constant, and replace the javadoc that defended the
old behaviour. A param that is a validation dimension still voids the policy,
including when it fails alongside disabled.

Co-Authored-By: Claude Opus 5 <[email protected]>
The two WARN messages in resolveInto claimed consequences the injector does
not control. The ReflectionException branch said the params were 'marked
unusable', but InterceptorParams.unresolved is a defaulted no-op, so only a
holder that overrides it - UploadPolicy does - degrades at all. The
unresolved-expression branch said the configured value was kept, which reads
as a sensible fallback when it is normally the unevaluated ${...} literal
applied at build time.

Both now report only the injector's own actions: the value was not written
and the holder was notified. The nuance about what the holder retains moves
to the javadoc, where there is room to state it accurately.

Co-Authored-By: Claude Opus 5 <[email protected]>
…ration time

A param name that no property on the params holder can accept was only
noticed per request: resolveInto caught the ReflectionException, warned, and
notified the holder - which for UploadPolicy means rejecting every upload of
every request behind a WARN. The names are fully known when the configuration
is parsed, so DefaultInterceptorFactory now fails with a ConfigurationException
naming the interceptor, the param and the holder type.

Only the interceptor-ref params are checked. InterceptorBuilder passes that
same map on to the InterceptorMapping, and DefaultActionInvocation.mergedParams
feeds it to resolveInto, so it is exactly the set that reaches the holder.
Params on the <interceptor> definition are applied to the interceptor instance
and never reach the mapping; checking them too would reject working config,
<param name="disabled"> on a definition being the obvious case.

The runtime handling stays as defence in depth. ConfigurationException is now
rethrown rather than swallowed by the generic catch, so the operator reads the
param name instead of "Caught Exception while registering Interceptor class".

Co-Authored-By: Claude Opus 5 <[email protected]>
…onal

The overload lost its last caller when the mapping name became available at
the call site, so an existing subclass override would have compiled and then
never run again - silently dead code, worse than a compile error. This branch
already changes the protected acceptFile signature, so keeping the one-arg
form for source compatibility was not consistent either.

Covered by a test asserting the surviving two-arg form is the extension point
and receives the mapping name.

Co-Authored-By: Claude Opus 5 <[email protected]>
…ptor params

mergedParams built a HashMap, so the order the configuration carries was
discarded and the order params were applied to the per-invocation holder was
whatever hashing produced. LinkedHashMap makes it deterministic and matches
the LinkedHashMap the InterceptorBuilder already assembles.

Co-Authored-By: Claude Opus 5 <[email protected]>
Interceptor extends Serializable, so an interceptor holding its configured
params as a field must hold something serializable. The fields UploadPolicy
replaced were a Long and two HashSets, all serializable; the holder was not,
which silently broke serialization of every file upload interceptor.

Fix it on the contract rather than the field: InterceptorParams now extends
Serializable, so every holder inherits the requirement. Marking the field
transient would instead have dropped the configured policy on deserialization.

Also renames four test locals that shadowed the interceptor field and drops
a throws clause that could not be reached, both reported by SonarCloud.
…y lambdas

Each lambda called both params(...) and buildInterceptor(...), so a throw from
the helper would have satisfied the assertion just as well as one from the code
under test. Building the map first leaves one throwing call per lambda.

Reported by SonarCloud (java:S5778).
…ions

A ${...} param is resolved per invocation into the params holder, so applying
its raw text to the interceptor at configuration time only seeded an
unevaluated literal - allowedTypes held "${uploadConfig.allowedMimeTypes}",
matching no content type - or failed conversion outright for a typed property
such as the Long maximumSize.

Withhold those params at build time; static params still apply and still seed
the holder, which is what a lazy param falling back has to fall back to. Also
makes InterceptorParams.unresolved's javadoc about the retained value honest.

Idea from @deprrous in GitHub PR #1815.

Co-Authored-By: deprrous <[email protected]>
@sonarqubecloud

Copy link
Copy Markdown

@lukaszlenart
lukaszlenart marked this pull request as ready for review July 27, 2026 15:20
@lukaszlenart
lukaszlenart requested a review from rgielen July 27, 2026 15:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants