Skip to content

WW-5712 Cover Jackson any-setters in REST parameter authorization - #1889

Open
carrerasdarren-cell wants to merge 2 commits into
apache:mainfrom
carrerasdarren-cell:ww-5712-rest-anysetter-hardening
Open

WW-5712 Cover Jackson any-setters in REST parameter authorization#1889
carrerasdarren-cell wants to merge 2 commits into
apache:mainfrom
carrerasdarren-cell:ww-5712-rest-anysetter-hardening

Conversation

@carrerasdarren-cell

@carrerasdarren-cell carrerasdarren-cell commented Aug 31, 2026

Copy link
Copy Markdown

Fixes WW-5712.

This adds opt-in authorization coverage for Jackson any-setters in the REST
plugin:

  • adds @StrutsParameter(allowDynamicKeys = true) for explicit dynamic-key
    consent;
  • adds struts.rest.anySetter.requireAnnotations, defaulting to false for
    compatibility;
  • applies the consent and depth() limit to method and field any-setters;
  • rejects creator-parameter any-setters when enforcement is enabled; and
  • keeps JSON and XML behavior aligned while preserving existing behavior when
    enforcement is disabled.

Verification:

  • 142 REST plugin tests passed;
  • 83 focused core parameter-authorization and annotation tests passed;
  • REST plugin verify and Apache RAT passed; and
  • git diff --check passed.

@lukaszlenart

Copy link
Copy Markdown
Member

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.

Assisted-by: OpenAI Codex
@carrerasdarren-cell

Copy link
Copy Markdown
Author

Thanks for the detailed review. Addressed in 514b92bf4:

  • reject a detached any-setter parser when currentName() is null, before creating either authorization scope;
  • place both scope pushes under guarded finally cleanup;
  • clear dynamic-key request state after every Jackson JSON/XML mapper read, including exceptional exits;
  • make the module configuration flag volatile; and
  • clarify the ordinary query/form binding effect of placing @StrutsParameter on a field.

I added regressions for the null-name token-buffer path and for request-boundary cleanup after a failed Jackson read. Verification after the update: 41 focused authorization tests pass, all 144 REST plugin tests pass, REST plugin verify and Apache RAT pass, and git diff --check passes.

@lukaszlenart

Copy link
Copy Markdown
Member

Thanks for this, and apologies for the wait — the review is on us, not you.

The shape is what we agreed: sink-level consent on the any-setter, default off
(struts.rest.anySetter.requireAnnotations=false), wrapper not even installed unless an
application opts in. I'm happy with that part and with the REJECTED_VALUE sentinel handling —
I checked that it can't leak through PropertyValue.AnyProperty.assign or AnyParameter.assign,
since both route through SettableAnyProperty.set, which you override.

A couple of notes, none of them blocking.

Depth semantics — checked, and they're right

I went in suspecting the dynamic key might not consume its share of the depth budget, and that the
method form and the Map-typed field form might disagree because prefixForNested
(AuthorizingSettableAnyProperty.java:259-265) only appends [0] for the map-like case. I wrote a
parity test for both forms and it came out clean — same grant, same body, same reach:

METHOD depth=1 -> ACCEPTED home.city=Warsaw
FIELD  depth=1 -> ACCEPTED home.city=Warsaw

which is as it should be: an any-setter on the root object takes its keys as root-level properties,
so {"home":{"city":"Warsaw"}} authorizes home and then home.city — one nesting character,
depth = 1, exactly the rule @StrutsParameter uses everywhere else. The gate is valueDepth
computed on the JSON, so it doesn't vary by declaration form. Mentioning it only so you know it was
checked rather than assumed.

The one gap it did show up: the suite pins the nested cases only for the method form
(testDynamicKeyDepthZero/One/TwoAnySetterBean), while the field form is covered for scalars alone.
Worth adding a nested field-form case so the equivalence is held by a test rather than by
inspection — happy to hand you the one I wrote.

I should also say plainly, since it's the thing most likely to be raised at you later: within a
granted dynamic-key scope the annotation authorizer is deliberately not consulted for members
beneath the key, and your tests assert exactly that (a deny-everything authorizer, nested member
still set). That is correct and consistent with the rest of the framework —
StrutsParameterAuthorizer.isAuthorized authorizes the root property plus a total depth budget, so
user.admin needs an annotation on user and never on User.admin. No change wanted there.

Smaller items

  • AuthorizingSettableAnyProperty.java:155parser.currentName() is null when Jackson routes
    through BeanDeserializer._deserializeUsingPropertyBasedWithUnwrapped, which passes a value-only
    TokenBuffer. A bean combining @JsonUnwrapped with a @JsonCreator and an any-setter therefore
    drops every dynamic property, even when correctly annotated. Fail-closed, but silent — worth at
    least a distinguishable log, ideally handling.
  • :209-211allowedDepth() short-circuits on creatorParameter before looking at permission,
    so a creator-parameter any-setter is rejected unconditionally with no opt-in. The annotation is
    discoverable there (prop.getMember() is the AnnotatedParameter); if the intent is that it's
    unsupported, the WARN should say so permanently rather than read like a missing annotation.
  • :221 — one WARN per rejected key, and unlike bean properties the key space is attacker-
    controlled and unbounded. We've had a run of DoS-shaped reports lately, so please log once per
    body with a count instead.
  • ParameterAuthorizingModule.java:94setRequireAnySetterAnnotations only takes effect at
    deserializer-build time and Jackson caches those per mapper, so a call after the mapper is in use
    is silently a no-op. Both handlers already have the (boolean) constructor; injecting the
    constant there removes the hazard rather than documenting it.
  • TokenBuffer.asCopyOfValue(JsonParser) (:122,168,243) is deprecated since Jackson 2.13 in
    favour of DeserializationContext.bufferAsCopyOfValue(p), and context is in scope at both sites.

The @StrutsParameter documentation correction is on me, not you — it's tracked on WW-5712 and I'll
handle it alongside the ModelDriven wording.

One process note: because this touches parameter authorization, I'll run our security review over
the branch before merging. That's routine for this area and not a comment on the change.

Credit for the report and the patch is yours in the release notes.

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