[
https://issues.apache.org/jira/browse/WW-5712?focusedWorklogId=1038991&page=com.atlassian.jira.plugin.system.issuetabpanels:worklog-tabpanel#worklog-1038991
]
ASF GitHub Bot logged work on WW-5712:
--------------------------------------
Author: ASF GitHub Bot
Created on: 01/Sep/26 05:40
Start Date: 01/Sep/26 05:40
Worklog Time Spent: 10m
Work Description: lukaszlenart commented on PR #1889:
URL: https://github.com/apache/struts/pull/1889#issuecomment-5489440532
Thanks Darren — I reviewed this closely, with most of the attention on the
one change that
touches the existing enforcement path rather than on the new code.
## On the redirect of the existing checks
`AuthorizingSettableBeanProperty` (lines 86, 107) and
`AuthorizingValueDeserializer` (line 61)
now consult `DynamicKeyAuthorizationContext.isAuthorized(path)` instead of
`ParameterAuthorizationContext.isAuthorized(path)`. That path runs on every
REST body, since
`struts.parameters.requireAnnotations=true` is the 7.x default, so it was
worth being sure about.
It holds. `DynamicKeyAuthorizationContext.isAuthorized` falls through to the
original check
whenever the scope deque is empty, a scope is only ever pushed from
`AuthorizingSettableAnyProperty`,
and that class is only installed when the new constant is on — which
`struts-plugin.xml` ships as
`false`. So with the default configuration the deque is always empty and
both classes behave exactly
as before. With enforcement on, I could not construct a case where a scope
is live while something
outside the sink subtree is deserialized; the `@JsonUnwrapped` token replay
in `BeanDeserializer`
runs after the scope has been popped.
The rest also checks out: the boundary-character test in `remainingDepth`
means an empty or
nesting-char-bearing dynamic key tightens the check rather than escaping it,
and it agrees with
`NESTING_CHARS` in the core authorizer; `limitForNestedScope` narrows
monotonically so a nested
any-setter cannot buy back depth budget; `REJECTED_VALUE` cannot reach
application state, because
every consumer of `deserialize`'s return value goes through the
identity-filtered `set`; and the
creator-parameter form is fail-closed, with the creator receiving an empty
map rather than a
partially populated one.
## One thing to fix before merge
`AuthorizingSettableAnyProperty:129-130` and `:161-162` — the scope can
leak, and it leaks onto a
pooled container thread.
`deserialize()` takes its path from `parser.currentName()`, which is `null`
when Jackson hands it a
detached `TokenBuffer` parser (`deserializeWithUnwrapped` with a
property-based creator and an
any-setter on the same bean). `pathFor(null)` returns `null`, and `pushPath`
does
`PATH_STACK.get().push(null)` on an `ArrayDeque`, which throws NPE. That
`pushPath` sits outside the
`try`, immediately after `DynamicKeyAuthorizationContext.push(...)`, so the
scope is never popped —
and `ParameterAuthorizationContext.unbind()` clears `STATE`, `PATH_STACK`
and `REDACTION_STACK` but
knows nothing about the new `SCOPES` ThreadLocal, so it survives the request.
The direction is safe — the leaked scope has a null `basePath`,
`remainingDepth` returns `-1` for
that, so the thread fails closed and denies everything — but a thread that
silently drops every REST
body parameter until the container recycles it is a bad failure mode. Three
cheap changes:
1. Move `DynamicKeyAuthorizationContext.push(...)` inside the `try`, or put
both pushes under one
`try`/`finally`, so the scope cannot outlive the frame that created it.
2. Guard `deserialize()` on a null current name and reject explicitly,
rather than letting it reach
`pathFor`.
3. Have the scope stack cleared alongside the other ThreadLocals when the
request ends, so no future
leak can cross a request boundary. Adding it to
`ParameterAuthorizationContext.unbind()` would
invert the dependency, so probably a
`DynamicKeyAuthorizationContext.clear()` called from the same
place in `ContentTypeInterceptor`'s `finally`.
A regression test for the null-name path would be worth having, since it is
not currently covered.
## Smaller points
- `ParameterAuthorizingModule.requireAnySetterAnnotations` is a non-final
field mutated after the
`ObjectMapper` has been constructed. The ordering works today — injection
happens at container
build time, and `registerModule` builds no deserializers — but the
javadoc's "set this before the
mapper is first used" is a constraint the type cannot enforce. Marking the
field `volatile` costs
nothing and documents the cross-thread read.
- The javadoc on `allowDynamicKeys` says ordinary parameter injection
ignores the flag. True, but on
the field form the `@StrutsParameter` annotation itself still makes that
map bindable from ordinary
query and form parameters. Worth a sentence, so nobody reads the
annotation as inert on that
channel.
- An opted-in any-setter's `depth` can exceed the depth declared on the
member above it — a
`depth = 1` setter holding a bean whose sink declares `depth = 2` grants
the deeper path
structurally. Strictly more restrictive than before this change, and the
`depth = 2` is an explicit
declaration, so I am not asking for a behaviour change; a test pinning the
intended semantics would
be useful though.
Nothing here changes the shape of the design — the sink-level consent model,
the default-off constant
and the depth accounting all land the way we discussed. Fix the scope leak
and I am happy with it.
Issue Time Tracking
-------------------
Worklog Id: (was: 1038991)
Time Spent: 20m (was: 10m)
> ParameterAuthorizingModule does not wrap the Jackson any-setter in the REST
> plugin
> ----------------------------------------------------------------------------------
>
> Key: WW-5712
> URL: https://issues.apache.org/jira/browse/WW-5712
> Project: Struts 2
> Issue Type: Bug
> Reporter: Lukasz Lenart
> Priority: Major
> Fix For: 7.4.0
>
> Time Spent: 20m
> Remaining Estimate: 0h
>
> {{ParameterAuthorizingModule.updateBuilder}} wraps the properties returned by
> {{BeanDeserializerBuilder.getProperties()}} with
> {{AuthorizingSettableBeanProperty}}, but it never consults
> {{BeanDeserializerBuilder.getAnySetter()}}. The {{SettableAnyProperty}} is
> therefore left unwrapped, so unknown keys dispatched to a Jackson
> {{@JsonAnySetter}} are set without passing through the {{@StrutsParameter}}
> authorization context — while an ordinary unannotated setter on the same
> class, in the same request body, is correctly rejected.
> All three any-setter forms are affected (method, field and {{@JsonCreator}}
> parameter). Both {{JacksonJsonHandler}} and {{JacksonXmlHandler}} register
> the module, so JSON and XML bodies behave the same. {{@JsonUnwrapped}} is not
> affected, and an any-setter beneath a rejected parent is already skipped
> through {{skipChildren()}}.
> The obvious fix does not work: path-based authorization resolves a
> parameter's root name against declared members of the target, so
> {{pathFor(key)}} matches nothing for a dynamic key by construction. Routing
> any-setter keys through the existing check would reject every key and break
> every existing {{@JsonAnySetter}} user. Consent has to be expressed at the
> sink rather than at the key.
> Proposed shape:
> * A new annotation member, {{@StrutsParameter(allowDynamicKeys = true)}},
> marking one any-setter as permitted to receive arbitrary keys. A plain
> {{@StrutsParameter}} is deliberately not sufficient — arbitrary-key access
> should have to be typed out, not granted by accident.
> * Enforcement behind a plugin-local constant,
> {{struts.rest.anySetter.requireAnnotations}}, defaulting to {{false}}, so
> nothing changes for existing applications until they opt in. Same shape as
> {{struts.chaining.requireAnnotations}}; the default can be revisited in a
> major release.
> * {{depth()}} keeps its current meaning, counted beneath the dynamic key. The
> default {{depth = 0}} accepts a scalar value under any key and rejects a
> nested object under it; {{depth = 1}} permits one level of nesting. That is
> the rule an annotated {{Map}} property already follows.
> * {{@Target}} stays {{METHOD, FIELD}}. A {{@JsonCreator}}-parameter
> any-setter has nowhere to carry the marker and is rejected with a warning
> when enforcement is on. Narrowing that rare form now is preferable to
> advertising an annotation placement the framework does not honour elsewhere,
> and {{@Target}} can be widened later but never narrowed.
> * The wrapper mirrors {{AuthorizingSettableBeanProperty}}: delegate untouched
> when the authorization context is inactive; on rejection log a warning, mark
> the context redacted and call {{skipChildren()}} so nested graphs are never
> constructed.
> Test coverage should include default-off compatibility, the method and field
> forms, missing and explicit consent, depth 0/1/2, creator parameters,
> unauthorized parents, {{@JsonUnwrapped}}, both the JSON and XML handlers, and
> context cleanup.
> Documentation follow-up: the {{@StrutsParameter}} page states that the JSON
> and REST plugins authorize each property during deserialization "so that
> unauthorized fields are never set". That is not accurate as written — correct
> the wording and document the any-setter behaviour explicitly.
> Reported by Darren Carreras (GitHub: carrerasdarren-cell), who has a patch
> implementing the above and will open the pull request.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)