Skip to content

WW-5585 Keep dynamic upload validation request-scoped - #1815

Closed
deprrous wants to merge 5 commits into
apache:mainfrom
deprrous:bugfix/ww-5585-request-scoped-upload-validation
Closed

WW-5585 Keep dynamic upload validation request-scoped#1815
deprrous wants to merge 5 commits into
apache:mainfrom
deprrous:bugfix/ww-5585-request-scoped-upload-validation

Conversation

@deprrous

Copy link
Copy Markdown
Contributor

Follow-up to WW-5585.

When allowedTypes, allowedExtensions, or maximumSize are resolved lazily from ${...}, those resolved values currently get written back onto the shared interceptor instance. That lets concurrent requests bleed into each other before file validation runs.

This keeps the lazily resolved upload policy request-scoped for the lifetime of the interceptor call and clears it afterwards. Static interceptor configuration still behaves the same.

Tests:

  • ./mvnw -pl core -Dtest=org.apache.struts2.interceptor.ActionFileUploadInterceptorTest#testConcurrentDynamicPoliciesStayIsolatedPerRequest test
  • ./mvnw -pl core -Dtest=org.apache.struts2.interceptor.ActionFileUploadInterceptorTest,org.apache.struts2.DefaultActionInvocationTest test

Follow-up to the dynamic upload-policy support added in WW-5585.

When lazy upload parameters are resolved per request, keep the effective policy on request-local state instead of writing it back to the shared interceptor instance. Add a regression that covers the concurrent overwrite case.
Copilot AI review requested due to automatic review settings July 27, 2026 02:56

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 addresses a concurrency issue in Struts’ file upload validation when allowedTypes, allowedExtensions, or maximumSize are lazily resolved via ${...}: previously, the resolved values could be written back onto a shared interceptor instance and bleed across concurrent requests. The change keeps dynamically-resolved upload validation policy request-scoped for the lifetime of the interceptor invocation and clears it afterward, while preserving existing behavior for static interceptor configuration.

Changes:

  • Add a request-scoped UploadValidationPolicy (via ThreadLocal) in AbstractFileUploadInterceptor and use it during acceptFile(...).
  • Track when lazy param injection is in progress (WithLazyParams.LazyParamInjector), so setters can route dynamically injected values into the request-scoped policy rather than the interceptor’s shared fields.
  • Ensure request-scoped policy is cleared in ActionFileUploadInterceptor.intercept() and add a concurrent regression test.

Reviewed changes

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

File Description
core/src/test/java/org/apache/struts2/interceptor/ActionFileUploadInterceptorTest.java Refactors dynamic-policy tests to inject ${...} params and adds a concurrency regression test to ensure per-request isolation.
core/src/main/java/org/apache/struts2/interceptor/WithLazyParams.java Adds a thread-local “injection in progress” marker around lazy param injection.
core/src/main/java/org/apache/struts2/interceptor/ActionFileUploadInterceptor.java Clears request-scoped upload validation policy in a finally block after interception.
core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java Implements request-scoped upload validation policy storage and uses it during file acceptance checks.

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

Comment on lines +960 to +967
private String runUploadAttempt(ActionFileUploadInterceptor actionFileUploadInterceptor,
MyDynamicFileUploadAction action,
MockHttpServletRequest uploadRequest,
String allowedTypes) throws Exception {
MultiPartRequestWrapper multiPartRequest = createMultipartRequest(uploadRequest, -1, -1, 3, -1);
ValueStack valueStack = container.getInstance(ValueStackFactory.class).createValueStack();
valueStack.push(action);

@lukaszlenart

Copy link
Copy Markdown
Member

Thanks for this — the underlying problem is real and you diagnosed it correctly. I traced it independently and it holds up:

LazyParamInjector#injectParams (WithLazyParams.java:78-84) writes resolved values straight onto the interceptor, and that interceptor is a config-time singleton (InterceptorBuilder.java:73-74, :175-177). It's worse than same-action concurrency: InterceptorBuilder.java:79 hands out the same InterceptorMapping objects to every action that references a stack without overriding params, so the instance is shared across actions too. Between DefaultActionInvocation.java:269 (resolve, write shared fields) and :275acceptFile (read shared fields) there is no guard at all. Good catch.

I don't want to merge this particular fix though, for a few reasons:

  1. static ThreadLocal PARAM_INJECTION_IN_PROGRESS makes setAllowedTypes/setAllowedExtensions/setMaximumSize behave differently depending on hidden thread state. A setter whose target depends on an ambient flag is hard to reason about and hard to test.
  2. clearRequestScopedUploadValidationPolicy() is only called from ActionFileUploadInterceptor#intercept's finally. WithLazyParams is public API — any other implementer that forgets that call leaks the ThreadLocal.
  3. The unsafe write path still exists; it's conditionally bypassed rather than removed. The next WithLazyParams implementer gets the original bug back by default.
  4. It doesn't cover DefaultActionInvocation.java:262-267, which does params.putAll(...) on the live shared map returned by InterceptorMapping#getParams (:60-62) — an unsynchronised write to a shared HashMap on every request.

So rather than patch the one implementer, I'd like to fix the WithLazyParams contract so the failure mode can't be expressed. The direction is to have resolved params written into a per-invocation object that the interceptor supplies and receives back, leaving the singleton immutable after init():

public interface WithLazyParams<P> {
    P newLazyParams();
    String intercept(ActionInvocation invocation, P lazyParams) throws Exception;
}

That keeps OGNL type conversion (the holder has typed setters, so maximumSize still converts to Long), removes the mutation entirely, and makes the data flow an explicit argument instead of ambient state. It's an interface change, so it targets the next minor rather than 7.2.x.

I've filed WW-5659 to track it and I'm writing up the design now. Your regression test is the valuable part of this PR — the scenario it pins down is exactly what the new design has to keep passing, and I'd like to carry it over (with attribution) once the interface lands. If you'd like to take the implementation on the new contract, say so and it's yours; otherwise I'll pick it up and credit you on the ticket.

One process note for next time, not a criticism of this one: when a change touches something that could weaken a security control — upload validation, parameter filtering, OGNL access — please email security@struts.apache.org before opening a public PR. SECURITY.md covers why. We looked at this one and concluded it's a thread-safety defect rather than a vulnerability (it needs the opt-in ${...} config, and struts-default.xml:60 ships actionFileUpload with no params, so default setups write nothing), which is why it's fine to keep the discussion here.

@deprrous

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. I’d like to take WW-5659 on this branch direction. I’ll rework this around the explicit per-invocation lazy-params contract and keep the concurrency regression scenario in place.

@deprrous

Copy link
Copy Markdown
Contributor Author

Pushed a rework on top of this branch for WW-5659.

The lazy params now live in a per-invocation holder instead of mutating the singleton interceptor instance. DefaultActionInvocation resolves a fresh holder for each call, ActionFileUploadInterceptor consumes an explicit upload policy object, and the concurrent upload isolation regression is still in place. I also added a small factory regression for typed lazy params so ${...} values are deferred until invocation time instead of being pushed through singleton setters during interceptor construction.

lukaszlenart added a commit that referenced this pull request Jul 27, 2026
…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 <sukhbatsuugii2004@gmail.com>
@lukaszlenart

Copy link
Copy Markdown
Member

Thanks for the rework — I read it properly and it's a real step up. You independently found several things that took my side multiple review rounds to catch:

  • the ConditionalInterceptor interaction. AbstractInterceptor implements ConditionalInterceptor, so a lazy interceptor reaches intercept through executeConditional, and leaving the two branches as siblings silently voids the dynamic policy. You merged them.
  • the shared InterceptorMapping param map, now copied rather than mutated per request — and with LinkedHashMap, preserving configuration order.
  • the abstract-method problem, handled correctly with the explicit WithLazyParams.InvocationScoped.super.intercept(invocation) delegation. An interface default alone would not have satisfied AbstractInterceptor's abstract declaration.
  • the OGNL allowlist. ce7c3fcaa is the one that impressed me. Moving the write target off the interceptor means the new holder isn't allowlisted, and with struts.allowlist.enable=true — the shipped default — every upload gets rejected. I hit exactly the same trap and it broke CI on every job in WW-5659 Resolve lazy interceptor params per invocation #1816; the showcase integration tests were the only thing that caught it. You found it yourself and your ThreadAllowlist route is sound (ModelDrivenInterceptor sets the precedent, and java.lang.Object is excluded independently at SecurityMemberAccess:86, so the hierarchy walk doesn't widen anything).

Where this is going

I'm going to land #1816 rather than this PR, and I want to be straight with you about why, because your design had one genuine advantage over mine.

Your WithLazyParams.InvocationScoped<P> is an optional sub-interface, so WithLazyParams stays source-compatible and no third-party implementer breaks. That's better than my approach on that axis, and it would have allowed a 7.2.x patch for a bug that's live in 7.2.0 and 7.2.1. I weighed that seriously.

What settles it is release planning rather than design: 7.3.0 already carries other compatibility-breaking changes, so there is no 7.2.2 to target. Once the fix ships in a version that breaks compatibility anyway, the compat-preserving shape stops buying anything, and the tradeoff inverts — a single mandatory contract is easier to reason about than a mandatory one plus an optional one, and it removes the unsafe path instead of letting implementers opt out of it. In your version the legacy WithLazyParams branch still writes to the interceptor, so the race remains for anyone who doesn't adopt InvocationScoped.

Alongside that, #1816 carries work this PR doesn't: fail-closed handling when an expression can't be resolved (previously an unresolvable ${...} produced an empty allowlist, which reads as "no restriction" — validation silently off), configuration-time validation of param names so a typo fails at startup instead of rejecting uploads in production, and InterceptorParams extends Serializable because Interceptor is.

One bug worth knowing about, wherever it lands

In this PR a lazily resolved disabled is silently ignored. UploadValidationPolicy has no disabled property, filterFactoryTimeParams withholds it from the interceptor at build time, and ognlUtil.setProperty without throwPropertyExceptions no-ops rather than complaining — so <param name="disabled">${expr}</param> does nothing at all and shouldIntercept reads the interceptor's permanently-false field. Before your change it was racy but functional; after, it's quietly dead. That's what pushed me toward making the holder's disabled support explicit (DisableParams) and validating the param names at configuration time.

What I've taken from your PR

filterFactoryTimeParams is a better idea than what I had, and it's now in #1816 as 83d2b61dd with attribution to you and a Co-Authored-By trailer. My version seeded the interceptor with the raw ${...} literal, so allowedTypes held the string "${uploadConfig.allowedMimeTypes}" — meaningless as a policy value — and maximumSize failed conversion outright, since a Long can't hold that text. Withholding those params at build time is simply correct, and it also makes the "what value survives a failed resolution" documentation honest instead of hand-wavy.

Your concurrency regression test from the first round is also in #1816, carried over with attribution — the scenario it pins is the acceptance criterion for the whole change, and reverting the fix still makes it fail exactly as the bug describes.

So: I'm closing this one, but two pieces of it ship, and you're credited on the ticket and in the commits. The allowlist catch in particular was independent work on a trap that had already bitten me — thank you for it.

One process note, same as last time and still worth repeating: when a change touches upload validation, parameter filtering, or OGNL access, please email security@struts.apache.org before opening a public PR. SECURITY.md covers the reasoning. This one is a thread-safety defect rather than a vulnerability — it needs the opt-in ${...} config, and struts-default.xml:60 ships actionFileUpload with no params — which is why the discussion stayed here.

lukaszlenart added a commit that referenced this pull request Jul 30, 2026
* WW-5659 docs: design for request-scoped lazy interceptor params

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 <noreply@anthropic.com>

* WW-5659 docs: implementation plan for request-scoped lazy params

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 <noreply@anthropic.com>

* WW-5659 docs: clarify test base class constraint in the plan

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* WW-5659 feat(core): add InterceptorParams contract and DisableParams holder

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* WW-5659 feat(core): resolve lazy params into a holder instead of the interceptor

* WW-5659 docs(core): correct isUnresolved javadoc and pin empty-value 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.

* WW-5659 refactor(core): hold upload policy in one value object

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.

* WW-5659 fix(core): resolve lazy interceptor params per invocation

Co-Authored-By: deprrous <sukhbatsuugii2004@gmail.com>

* WW-5659 test(core): cover both lazy-params skip branches and per-invocation 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.

* WW-5659 fix(core): reject uploads when the policy cannot be resolved

* WW-5659 test(core): exercise real lazy param resolution in dynamic upload tests

* WW-5659 fix(core): mark params unusable when a lazy value cannot be applied

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 <noreply@anthropic.com>

* WW-5659 chore(core): harden the policy sets and tidy the lazy params 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 <noreply@anthropic.com>

* WW-5659 fix(core): allowlist the lazy params holder for OGNL member access

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 <noreply@anthropic.com>

* WW-5659 test(core): prove lazy params resolve with the OGNL allowlist 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 <noreply@anthropic.com>

* WW-5659 fix(core): stop an unresolvable disabled param from voiding the 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 <noreply@anthropic.com>

* WW-5659 docs(core): state what the lazy param injector actually did

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 <noreply@anthropic.com>

* WW-5659 feat(core): reject unknown lazy interceptor params at configuration 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 <noreply@anthropic.com>

* WW-5659 refactor(core): drop the deprecated single-arg executeConditional

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 <noreply@anthropic.com>

* WW-5659 fix(core): keep configuration order when merging lazy interceptor 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 <noreply@anthropic.com>

* WW-5659 fix(core): keep interceptor params serializable

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.

* WW-5659 test(core): hoist the params map out of the assertThatThrownBy 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).

* WW-5659 fix(core): stop seeding the interceptor with raw lazy expressions

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 <sukhbatsuugii2004@gmail.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: deprrous <sukhbatsuugii2004@gmail.com>
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.

3 participants