Skip to content

WW-5642 fix(rest): authorize @StrutsParameter on record/creator-bound REST body properties#1774

Open
g0w6y wants to merge 3 commits into
apache:mainfrom
g0w6y:fix/rest-jackson-creator-property-authorization
Open

WW-5642 fix(rest): authorize @StrutsParameter on record/creator-bound REST body properties#1774
g0w6y wants to merge 3 commits into
apache:mainfrom
g0w6y:fix/rest-jackson-creator-property-authorization

Conversation

@g0w6y

@g0w6y g0w6y commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Fixes WW-5642

Summary

ParameterAuthorizingModule enforces @StrutsParameter on REST/JSON body deserialization by wrapping each Jackson property's deserializeAndSet / deserializeSetAndReturn (AuthorizingSettableBeanProperty). Jackson never calls either method for creator-bound properties — Java records, @JsonCreator constructors, @ConstructorProperties — it instead calls SettableBeanProperty#deserialize directly, which Jackson declares final, so the existing wrapper cannot intercept it.

Practical effect: with struts.parameters.requireAnnotations enabled, any record-typed field anywhere in a REST action's request body (top-level or nested) is populated with no @StrutsParameter check at all, silently defeating the protection for that entire subtree. This is easy to hit unintentionally — record is the idiomatic way to model immutable REST DTOs on the Java versions Struts now targets.

Verified with a live reproduction before writing the fix: a record field with an unauthorized component was populated anyway; after the fix, the same component is correctly rejected while an authorized sibling component still passes through.

Fix

Add AuthorizingValueDeserializer, which wraps the property's value deserializer rather than the SettableBeanProperty — the value deserializer is what the final deserialize() method delegates to, so wrapping it is the only available interception point for the creator-bound path. It is installed from AuthorizingSettableBeanProperty#withValueDeserializer, scoped to CreatorProperty specifically, so ordinary setter/field/builder-pattern properties — already correctly authorized via the existing deserializeAndSet/deserializeSetAndReturn overrides — are not checked a second time (which would also double-push the path-authorization stack and produce incorrect nested paths).

Test plan

  • New regression test testRecordComponentAuthorizedByPath in ParameterAuthorizingModuleTest: a record-typed nested property with one authorized and one unauthorized component — confirms the unauthorized component is rejected and the authorized one still deserializes.
  • Full existing ParameterAuthorizingModuleTest suite (setter, builder, nested, collection, map, array authorization paths) passes unchanged — confirms no regression from scoping the new check to creator-bound properties only.
  • Full struts2-rest-plugin module test suite passes (106/106).

…dy properties

ParameterAuthorizingModule enforces @StrutsParameter on REST/JSON body
deserialization by wrapping each property's deserializeAndSet/
deserializeSetAndReturn. Jackson never calls either method for
creator-bound properties (Java records, @JsonCreator constructors,
@ConstructorProperties) — it calls SettableBeanProperty#deserialize
directly, which is declared final and bypasses the wrapper entirely.
With struts.parameters.requireAnnotations enabled, any record-typed
field anywhere in a REST action's request body was populated with no
authorization check at all.

Add AuthorizingValueDeserializer, which wraps the property's value
deserializer instead of the property itself, and install it from
AuthorizingSettableBeanProperty#withValueDeserializer — scoped to
CreatorProperty so ordinary setter/field/builder properties, already
authorized via the existing wrapper, aren't checked twice.
@lukaszlenart

Copy link
Copy Markdown
Member

Thanks for the PR but please create a JIRA ticket to cover this work 🙏

@g0w6y g0w6y changed the title fix(rest): authorize @StrutsParameter on record/creator-bound REST body properties WW-5642 fix(rest): authorize @StrutsParameter on record/creator-bound REST body properties Jul 10, 2026
@g0w6y

g0w6y commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Created WW-5642 to track this: https://issues.apache.org/jira/browse/WW-5642. Ready for review/merge whenever you get a chance thanks!

@g0w6y

g0w6y commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Given this fixes an authorization bypass where unauthorized REST body fields could be set, should we handle this as a security advisory instead of a normal fix? Happy to follow whichever process you prefer, just let me know.

@lukaszlenart

Copy link
Copy Markdown
Member

Thanks — the creator-bound gap is real and worth closing. As it stands though the coverage isn't complete: it handles records/@JsonCreator via withValueDeserializer, but primitive/required creator components (rejection returns null), collection-path handling, and the broader creator styles (@ConstructorProperties, @JsonCreator constructors, top-level records, collection/map creator params) aren't covered or tested. I'd want those addressed before it lands.

Are you planning to extend it, or would you prefer I take it forward under a linked JIRA?

…zed, not fatal

AuthorizingValueDeserializer substitutes null for a rejected creator-bound
property (record component, @JsonCreator/@ConstructorProperties param).
For reference-typed, unvalidated components this is a harmless stand-in
for "not set" -- but two cases turn that substitution into an unhandled
exception that crashes deserialization of the entire request body instead
of just dropping the unauthorized subtree:

- A record/constructor with its own non-null validation (e.g. a compact
  constructor doing Objects.requireNonNull) throws
  ValueInstantiationException when the redacted component reaches it.
- With DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES enabled, Jackson
  itself throws MismatchedInputException when a primitive-typed creator
  component is redacted to null.

Add RedactionAwareDeserializer, wrapping every bean-type deserializer via
a new BeanDeserializerModifier#modifyDeserializer hook. It tracks (via a
new redaction-scope stack in ParameterAuthorizationContext) whether the
object currently under construction had a property redacted by
authorization; if construction then throws, the object is treated as
unauthorized (returns null) instead of propagating the raw exception --
matching the same fail-closed outcome already used when a non-creator
nested property is rejected outright. A guard test confirms genuine,
unrelated validation failures (nothing redacted) still propagate
normally, so real client errors aren't masked.

Also verified (and added regression coverage for) the other gaps raised
in review: static factory-method @JsonCreator, @ConstructorProperties,
top-level records, 3-level nested creator chains, and List/Map creator
params whose elements are further creator-bound or plain-POJO types --
all of these were already handled correctly by the existing
withValueDeserializer interception.
@g0w6y

g0w6y commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a fix for the gaps above (redacted creator-bound construction failures now fail closed instead of throwing, with regression tests for the other creator styles) and since this is an authz bypass, should it go through a security advisory like #1775/#1776?

@lukaszlenart

Copy link
Copy Markdown
Member

I recommend reading SECURITY guideline first to understand how responsible disclosure looks like

@lukaszlenart

Copy link
Copy Markdown
Member

Thanks for the thorough follow-up — 0c93ea2fa closes the completeness gaps I raised. I re-ran both suites locally against the PR head:

  • ParameterAuthorizationContextTest (core) — green
  • ParameterAuthorizingModuleTest (rest) — 23/23 green

The RedactionAwareDeserializer + redaction-scope stack is a clean way to handle the construction-failure case: substituting a stand-in for a dropped creator-bound component and then treating a resulting construction failure as "drop the whole object" is consistent with how a rejected non-creator nested property already behaves. Good that testValidatingRecord_genuineClientErrorStillPropagates pins down the other side of it — a construction failure with nothing dropped still propagates, so genuine client/data errors aren't masked. Coverage across records, static-factory @JsonCreator, @ConstructorProperties, top-level records, 3-level nesting, and List/Map creator params is exactly the matrix I was after.

Three small, non-blocking notes:

  1. Array-typed creator param. prefixForNested handles type.isArray(), but only List/Map element paths are exercised in tests — an Item[] creator-param case would round out the collection matrix.
  2. FAIL_ON_NULL_FOR_PRIMITIVES off (the default). With it disabled, a dropped primitive creator component silently becomes the type default (0/false) rather than dropping the object. That's fine — the point is the client value never lands — but worth a one-line comment so it reads as a deliberate choice rather than an oversight.
  3. Co-located failures. If the same object both had a property dropped and hit an unrelated JsonMappingException, the current scope is marked, so the unrelated error is folded into "object dropped." Harmless in outcome, just slightly less informative to the caller — fine to leave, worth being aware of.

None of these block. Nice work tightening it up.

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 strengthens Struts REST/Jackson request-body parameter authorization so @StrutsParameter enforcement also applies to creator-bound properties (Java records, @JsonCreator/@ConstructorProperties), closing an authorization gap when struts.parameters.requireAnnotations is enabled.

Changes:

  • Add AuthorizingValueDeserializer and wire it via AuthorizingSettableBeanProperty#withValueDeserializer (scoped to CreatorProperty) to authorize creator-bound properties that bypass deserializeAndSet / deserializeSetAndReturn.
  • Add RedactionAwareDeserializer wrapper (installed from ParameterAuthorizingModule) plus ParameterAuthorizationContext redaction-scope tracking to fail-closed (drop object) when redaction causes creator/builder construction failures.
  • Expand REST plugin tests with regression coverage for records, creators, nested/collection/map paths, and redaction-triggered construction failures.

Reviewed changes

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

Show a summary per file
File Description
plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java Adds regression tests covering record/creator-bound authorization paths and redaction-driven fail-closed behavior.
plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/RedactionAwareDeserializer.java New bean-deserializer wrapper to drop objects (return null) when construction fails after authorization redaction.
plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModule.java Installs RedactionAwareDeserializer via BeanDeserializerModifier#modifyDeserializer.
plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java New value-deserializer wrapper to enforce authorization for creator-bound properties.
plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableBeanProperty.java Wraps creator-property value deserializers; marks redaction when rejecting properties.
core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContext.java Adds redaction-scope stack APIs (push/popRedactionScope, markRedacted, wasRedactedInCurrentScope).
core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContextTest.java Adds unit tests for redaction-scope semantics and cleanup on unbind().

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

Comment on lines +73 to +84
try {
return super.deserialize(p, ctxt);
} catch (JsonMappingException e) {
if (!ParameterAuthorizationContext.wasRedactedInCurrentScope()) {
throw e;
}
LOG.warn("REST body object of type [{}] failed to construct after @StrutsParameter " +
"redaction dropped one of its properties; treating the object as unauthorized: {}",
handledType() != null ? handledType().getName() : "?", e.getMessage());
swallowed = true;
return null;
}
Addresses the three non-blocking review notes on WW-5642:

- Add testArrayOfRecordsAsCreatorParam_elementsAuthorizedByIndexedPath
  and a WithArray fixture, exercising the type.isArray() branch of
  AuthorizingValueDeserializer#prefixForNested so the collection matrix
  (List/Map/array) is fully covered.
- Document in AuthorizingValueDeserializer that redacting a primitive
  creator component becomes the type default (0/false) when
  FAIL_ON_NULL_FOR_PRIMITIVES is off -- a deliberate choice, the client
  value never lands either way.
- Document in RedactionAwareDeserializer that a redaction co-located with
  an unrelated mapping error is folded into "object dropped" -- a
  deliberate fail-closed trade-off, never exposing a partial object.
@g0w6y

g0w6y commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all three notes in 3b184bf.

Added the array creator param test (testArrayOfRecordsAsCreatorParam) with a WithArray fixture, so the collection matrix now covers List, Map and array. Also added the two clarifying comments you suggested: one in AuthorizingValueDeserializer noting that a redacted primitive component falls back to the type default when FAIL_ON_NULL_FOR_PRIMITIVES is off, and one in RedactionAwareDeserializer noting that a redaction co located with an unrelated mapping error is folded into object dropped as a deliberate fail closed choice.

Full ParameterAuthorizingModuleTest suite passes locally (24 tests). This change lives in the REST Jackson plugin and is independent of the JSON plugin per request refactor in #1782, so it can land on its own. Ready whenever you are.

@lukaszlenart

Copy link
Copy Markdown
Member

Thanks for this — the analysis is correct and the fix is well-targeted. To frame it for anyone reading: the per-property @StrutsParameter enforcement introduced in 7.2.0 covered Jackson's setter/field path but not the creator-bound path (records, @JsonCreator, @ConstructorProperties), which Jackson populates through the value deserializer. This PR completes that enforcement rather than fixing a regression — before 7.2.0 REST bodies had no per-property control at all, so nothing that was previously protected was exposed.

The approach (wrapping the creator-property value deserializer, plus fail-closing construction that fails after a redaction) is sound, and the record / static-factory / @ConstructorProperties / nested / collection test matrix is exactly what I'd want to see. I'll take it through review and merge once CI is green. Appreciate you also checking on the disclosure process up front — for this one, completing a brand-new control in the open is fine.

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