This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/main by this push:
     new 024aa661b WW-5725 Authorize the buffered creator path in 
AuthorizingSettableBeanProperty (#1916)
024aa661b is described below

commit 024aa661b6337ce0321d2acde13e2bf65649465c
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Sep 13 09:46:16 2026 +0200

    WW-5725 Authorize the buffered creator path in 
AuthorizingSettableBeanProperty (#1916)
    
    * WW-5725 fix(rest): authorize the buffered creator path in 
AuthorizingSettableBeanProperty
    
    AuthorizingSettableBeanProperty authorized a property in deserializeAndSet
    and deserializeSetAndReturn and wrapped the value deserializer only for
    creator-bound properties. Jackson takes neither route for a non-creator
    property it buffers during property-based creation: a setter property
    that appears in the body before the last creator parameter is read
    through the final SettableBeanProperty.deserialize(), with no
    authorization and no path push, and assigned after construction through
    PropertyValue.Regular.assign -> SettableBeanProperty.set(), which the
    Delegating base forwarded unchecked. The same property after the last
    creator parameter, or on a setter-only type, was already rejected, so
    member order alone decided whether the check applied, and the members
    of a buffered bean-valued property were checked one level too shallow.
    
    Three gates now cover the paths between them:
    
    - deserializeAndSet / deserializeSetAndReturn keep authorizing the
      direct path and skipping the value on rejection, so the setter never
      fires, but no longer push the path themselves.
    - AuthorizingValueDeserializer wraps every property's value
      deserializer, not only creator properties, and owns the path push for
      nested members. It now covers all three of Jackson's entry points --
      deserialize(p, ctxt), the in-place deserialize(p, ctxt, intoValue)
      used for setterless collections, and deserializeWithType for
      polymorphic properties -- and classifies the [0] element prefix on
      the property's declared type rather than the deserializer's handled
      type. On the direct path it re-checks a path deserializeAndSet
      already accepted; the authorizer call is stateless, so the answer is
      the same.
    - set / setAndReturn authorize the already-materialized assignment,
      which also covers a buffered null (Jackson skips the value
      deserializer for a null token) and the other callers of set() in
      jackson-databind: @JsonMerge, @JsonManagedReference, inner-class
      valued properties, EXTERNAL_PROPERTY type ids and the
      @JsonIdentityInfo id property, none of which were authorized before.
    
    All gates go through DynamicKeyAuthorizationContext so a dynamic-key
    scope authorizes by depth on every path.
    
    Tests cover the setter before and after the last creator parameter, a
    setter-only type with the same member order, a nested creator, a
    creator-plus-setter type inside a dynamic-key scope, a buffered
    bean-valued setter whose members must be authorized at their own
    depth, a setterless collection, and a polymorphic property -- the last
    two in the direction that matters: a sibling grant on the enclosing
    bean must not authorize a collapsed nested path.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5725 test(rest): cover the pass-through and rejection branches of the 
new gates
    
    Adds the no-context pass-through for a buffered setter, a setterless
    collection and a polymorphic property; the rejection of a buffered
    polymorphic setter (deserializeWithType); and an unauthorized @JsonMerge
    into an existing value (three-argument deserialize), which must leave
    the existing value untouched.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../jackson/AuthorizingSettableBeanProperty.java   |  74 ++++---
 .../jackson/AuthorizingValueDeserializer.java      |  81 +++++--
 .../jackson/ParameterAuthorizingModuleTest.java    | 246 +++++++++++++++++++++
 3 files changed, 351 insertions(+), 50 deletions(-)

diff --git 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableBeanProperty.java
 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableBeanProperty.java
index 14c3bcec7..a3365a8ff 100644
--- 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableBeanProperty.java
+++ 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingSettableBeanProperty.java
@@ -20,9 +20,7 @@ package org.apache.struts2.rest.handler.jackson;
 
 import com.fasterxml.jackson.core.JsonParser;
 import com.fasterxml.jackson.databind.DeserializationContext;
-import com.fasterxml.jackson.databind.JavaType;
 import com.fasterxml.jackson.databind.JsonDeserializer;
-import com.fasterxml.jackson.databind.deser.CreatorProperty;
 import com.fasterxml.jackson.databind.deser.SettableBeanProperty;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
@@ -37,10 +35,10 @@ import java.io.IOException;
  * skipped via {@link JsonParser#skipChildren()}, so any nested object graph 
is never instantiated
  * and setter side effects on unauthorized properties never fire.
  *
- * <p>Path tracking: the wrapper pushes the full path of the current property 
onto the context's
- * path stack before delegating, then pops in a {@code finally} block. For 
collection / map / array-typed
- * properties, the path pushed is suffixed with {@code [0]} so nested element 
members produce paths like
- * {@code items[0].field} — matching {@code ParametersInterceptor} depth 
semantics.</p>
+ * <p>Path tracking for nested members is done by the {@link 
AuthorizingValueDeserializer} wrapped
+ * around every property's value deserializer, so the direct path and the 
buffered creator path
+ * compute the same paths. Values Jackson assigns after construction go 
through {@link #set} and
+ * {@link #setAndReturn}, which apply the same authorization to the 
already-materialized value.</p>
  *
  * <p>When {@link ParameterAuthorizationContext#isActive()} is {@code false}, 
this wrapper is a
  * straight pass-through to the delegate — no overhead for default-config 
requests.</p>
@@ -61,17 +59,17 @@ public class AuthorizingSettableBeanProperty extends 
SettableBeanProperty.Delega
     }
 
     /**
-     * Creator-bound properties (records, {@code @JsonCreator} constructors) 
never reach
-     * {@link #deserializeAndSet}/{@link #deserializeSetAndReturn}: Jackson 
calls the {@code final}
-     * {@code SettableBeanProperty#deserialize} directly, through this 
property's own value deserializer.
-     * Wrap that deserializer with {@link AuthorizingValueDeserializer}, 
scoped to {@link CreatorProperty}
-     * so ordinary setter/field/builder properties -- already authorized below 
-- aren't double-checked.
+     * Creator-bound properties, and non-creator properties Jackson buffers 
while collecting creator
+     * parameters, never reach {@link #deserializeAndSet}/{@link 
#deserializeSetAndReturn}: Jackson calls
+     * the {@code final} {@code SettableBeanProperty#deserialize} directly, 
through this property's own
+     * value deserializer. Wrap that deserializer with {@link 
AuthorizingValueDeserializer} for every
+     * property; it owns the path push for nested members on both the direct 
and the buffered path.
      */
     @Override
     public SettableBeanProperty withValueDeserializer(JsonDeserializer<?> 
deser) {
         JsonDeserializer<?> effective = deser;
-        if (delegate instanceof CreatorProperty && !(deser instanceof 
AuthorizingValueDeserializer)) {
-            effective = new AuthorizingValueDeserializer(deser, getName());
+        if (!(deser instanceof AuthorizingValueDeserializer)) {
+            effective = new AuthorizingValueDeserializer(deser, getName(), 
getType());
         }
         return _with(delegate.withValueDeserializer(effective));
     }
@@ -90,12 +88,7 @@ public class AuthorizingSettableBeanProperty extends 
SettableBeanProperty.Delega
             p.skipChildren();
             return;
         }
-        ParameterAuthorizationContext.pushPath(prefixForNested(path));
-        try {
-            delegate.deserializeAndSet(p, ctxt, instance);
-        } finally {
-            ParameterAuthorizationContext.popPath();
-        }
+        delegate.deserializeAndSet(p, ctxt, instance);
     }
 
     @Override
@@ -111,24 +104,41 @@ public class AuthorizingSettableBeanProperty extends 
SettableBeanProperty.Delega
             p.skipChildren();
             return instance;
         }
-        ParameterAuthorizationContext.pushPath(prefixForNested(path));
-        try {
-            return delegate.deserializeSetAndReturn(p, ctxt, instance);
-        } finally {
-            ParameterAuthorizationContext.popPath();
+        return delegate.deserializeSetAndReturn(p, ctxt, instance);
+    }
+
+    @Override
+    public void set(Object instance, Object value) throws IOException {
+        if (isAuthorizedForSet(instance)) {
+            delegate.set(instance, value);
         }
     }
 
+    @Override
+    public Object setAndReturn(Object instance, Object value) throws 
IOException {
+        if (isAuthorizedForSet(instance)) {
+            return delegate.setAndReturn(instance, value);
+        }
+        return instance;
+    }
+
     /**
-     * For Collection / Map / Array properties, the path to push for nested 
element members is
-     * {@code path + "[0]"} — matching {@code ParametersInterceptor} 
bracket-depth semantics. Scalar /
-     * bean properties push the path unchanged.
+     * Guards the already-materialized assignment path: Jackson buffers 
non-creator properties seen
+     * before the last creator parameter and assigns them after construction 
via
+     * {@code PropertyValue.Regular.assign} -> {@code set()}, which does not 
go through
+     * {@link #deserializeAndSet}.
      */
-    private String prefixForNested(String pathOfThisProperty) {
-        JavaType type = getType();
-        if (type != null && (type.isCollectionLikeType() || 
type.isMapLikeType() || type.isArrayType())) {
-            return pathOfThisProperty + "[0]";
+    private boolean isAuthorizedForSet(Object instance) {
+        if (!ParameterAuthorizationContext.isActive()) {
+            return true;
+        }
+        String path = ParameterAuthorizationContext.pathFor(getName());
+        if (DynamicKeyAuthorizationContext.isAuthorized(path)) {
+            return true;
         }
-        return pathOfThisProperty;
+        LOG.warn("REST body parameter [{}] rejected by @StrutsParameter 
authorization on [{}]",
+                path, instance.getClass().getName());
+        ParameterAuthorizationContext.markRedacted();
+        return false;
     }
 }
diff --git 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java
 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java
index fc9532960..a30cfe8ef 100644
--- 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java
+++ 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java
@@ -20,36 +20,40 @@ package org.apache.struts2.rest.handler.jackson;
 
 import com.fasterxml.jackson.core.JsonParser;
 import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JavaType;
 import com.fasterxml.jackson.databind.JsonDeserializer;
 import com.fasterxml.jackson.databind.deser.std.DelegatingDeserializer;
+import com.fasterxml.jackson.databind.jsontype.TypeDeserializer;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
 
 import java.io.IOException;
-import java.util.Collection;
-import java.util.Map;
 
 /**
- * Enforces {@code @StrutsParameter} authorization for creator-bound 
properties (Java records,
- * {@code @JsonCreator} constructors, {@code @ConstructorProperties}), which 
Jackson deserializes
- * through the value deserializer directly rather than through a {@code 
SettableBeanProperty}.
- * See {@link AuthorizingSettableBeanProperty#withValueDeserializer} for where 
this is installed.
+ * Enforces {@code @StrutsParameter} authorization on a property's value 
deserializer, and owns the
+ * path push for its nested members. It is installed on every property by
+ * {@link AuthorizingSettableBeanProperty#withValueDeserializer}, so the same 
path is computed whether
+ * Jackson reaches the value through {@code deserializeAndSet}, through the 
{@code final}
+ * {@code SettableBeanProperty#deserialize} used for creator parameters and 
buffered properties, in
+ * place for a setterless collection, or through a type deserializer for a 
polymorphic property.
  */
 final class AuthorizingValueDeserializer extends DelegatingDeserializer {
 
     private static final Logger LOG = 
LogManager.getLogger(AuthorizingValueDeserializer.class);
 
     private final String propertyName;
+    private final JavaType propertyType;
 
-    AuthorizingValueDeserializer(JsonDeserializer<?> delegate, String 
propertyName) {
+    AuthorizingValueDeserializer(JsonDeserializer<?> delegate, String 
propertyName, JavaType propertyType) {
         super(delegate);
         this.propertyName = propertyName;
+        this.propertyType = propertyType;
     }
 
     @Override
     protected JsonDeserializer<?> newDelegatingInstance(JsonDeserializer<?> 
newDelegatee) {
-        return new AuthorizingValueDeserializer(newDelegatee, propertyName);
+        return new AuthorizingValueDeserializer(newDelegatee, propertyName, 
propertyType);
     }
 
     @Override
@@ -58,10 +62,7 @@ final class AuthorizingValueDeserializer extends 
DelegatingDeserializer {
             return super.deserialize(p, ctxt);
         }
         String path = ParameterAuthorizationContext.pathFor(propertyName);
-        if (!DynamicKeyAuthorizationContext.isAuthorized(path)) {
-            LOG.warn("REST body parameter [{}] rejected by @StrutsParameter 
authorization (creator-bound property)", path);
-            ParameterAuthorizationContext.markRedacted();
-            p.skipChildren();
+        if (!authorize(path, p)) {
             // Returning null redacts the value. For a primitive creator 
component this becomes the
             // type default (0/false) unless FAIL_ON_NULL_FOR_PRIMITIVES is on 
(then construction
             // fails and RedactionAwareDeserializer drops the whole object) -- 
either way the
@@ -76,15 +77,59 @@ final class AuthorizingValueDeserializer extends 
DelegatingDeserializer {
         }
     }
 
+    @Override
+    public Object deserialize(JsonParser p, DeserializationContext ctxt, 
Object intoValue) throws IOException {
+        if (!ParameterAuthorizationContext.isActive()) {
+            return super.deserialize(p, ctxt, intoValue);
+        }
+        String path = ParameterAuthorizationContext.pathFor(propertyName);
+        if (!authorize(path, p)) {
+            return intoValue;
+        }
+        ParameterAuthorizationContext.pushPath(prefixForNested(path));
+        try {
+            return super.deserialize(p, ctxt, intoValue);
+        } finally {
+            ParameterAuthorizationContext.popPath();
+        }
+    }
+
+    @Override
+    public Object deserializeWithType(JsonParser p, DeserializationContext 
ctxt, TypeDeserializer typeDeserializer)
+            throws IOException {
+        if (!ParameterAuthorizationContext.isActive()) {
+            return super.deserializeWithType(p, ctxt, typeDeserializer);
+        }
+        String path = ParameterAuthorizationContext.pathFor(propertyName);
+        if (!authorize(path, p)) {
+            return null;
+        }
+        ParameterAuthorizationContext.pushPath(prefixForNested(path));
+        try {
+            return super.deserializeWithType(p, ctxt, typeDeserializer);
+        } finally {
+            ParameterAuthorizationContext.popPath();
+        }
+    }
+
+    private boolean authorize(String path, JsonParser p) throws IOException {
+        if (DynamicKeyAuthorizationContext.isAuthorized(path)) {
+            return true;
+        }
+        LOG.warn("REST body parameter [{}] rejected by @StrutsParameter 
authorization", path);
+        ParameterAuthorizationContext.markRedacted();
+        p.skipChildren();
+        return false;
+    }
+
     /**
-     * For Collection / Map / Array-valued creator parameters, the path to 
push for nested element
-     * members is {@code path + "[0]"} -- matching {@code 
ParametersInterceptor} bracket-depth
-     * semantics, and {@link AuthorizingSettableBeanProperty#prefixForNested}. 
Scalar / bean-valued
-     * parameters push the path unchanged.
+     * For Collection / Map / Array properties, the path to push for nested 
element members is
+     * {@code path + "[0]"} -- matching {@code ParametersInterceptor} 
bracket-depth semantics. Scalar /
+     * bean-valued properties push the path unchanged.
      */
     private String prefixForNested(String pathOfThisProperty) {
-        Class<?> type = handledType();
-        if (type != null && (Collection.class.isAssignableFrom(type) || 
Map.class.isAssignableFrom(type) || type.isArray())) {
+        if (propertyType != null
+                && (propertyType.isCollectionLikeType() || 
propertyType.isMapLikeType() || propertyType.isArrayType())) {
             return pathOfThisProperty + "[0]";
         }
         return pathOfThisProperty;
diff --git 
a/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java
 
b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java
index 61ee26eaf..0cae3eabe 100644
--- 
a/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java
+++ 
b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModuleTest.java
@@ -20,7 +20,10 @@ package org.apache.struts2.rest.handler.jackson;
 
 import com.fasterxml.jackson.annotation.JsonAnySetter;
 import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonMerge;
 import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
 import com.fasterxml.jackson.annotation.JsonUnwrapped;
 import com.fasterxml.jackson.core.JsonParser;
 import com.fasterxml.jackson.databind.BeanDescription;
@@ -434,6 +437,130 @@ public class ParameterAuthorizingModuleTest extends 
TestCase {
         assertNull(result.secret);
     }
 
+    public void testSetterBufferedBeforeCreatorParamIsAuthorized() throws 
Exception {
+        // A non-creator property that appears before the last creator 
parameter is buffered by
+        // Jackson and assigned through SettableBeanProperty.set() after 
construction.
+        bind((path, t, a) -> "name".equals(path), new CreatorWithSetter(""));
+        CreatorWithSetter result = 
mapper.readValue("{\"role\":\"admin\",\"name\":\"alice\"}", 
CreatorWithSetter.class);
+        assertEquals("alice", result.name);
+        assertNull("buffered setter property assigned without authorization 
?", result.getRole());
+    }
+
+    public void testSetterAfterCreatorParamIsAuthorized() throws Exception {
+        // Control: the same property after the last creator parameter takes 
the direct path.
+        bind((path, t, a) -> "name".equals(path), new CreatorWithSetter(""));
+        CreatorWithSetter result = 
mapper.readValue("{\"name\":\"alice\",\"role\":\"admin\"}", 
CreatorWithSetter.class);
+        assertEquals("alice", result.name);
+        assertNull(result.getRole());
+    }
+
+    public void testSetterOnlyTypeSameMemberOrderIsAuthorized() throws 
Exception {
+        // Control: without a creator nothing is buffered, so member order 
does not matter.
+        bind((path, t, a) -> "name".equals(path), new Person());
+        Person result = 
mapper.readValue("{\"role\":\"admin\",\"name\":\"alice\"}", Person.class);
+        assertEquals("alice", result.name);
+        assertNull(result.role);
+    }
+
+    public void testNestedSetterBufferedBeforeCreatorParamIsAuthorized() 
throws Exception {
+        bind((path, t, a) -> "inner".equals(path) || 
"inner.name".equals(path), new CreatorHolder(null));
+        CreatorHolder result = mapper.readValue(
+                "{\"inner\":{\"role\":\"admin\",\"name\":\"alice\"}}", 
CreatorHolder.class);
+        assertNotNull(result.inner);
+        assertEquals("alice", result.inner.name);
+        assertNull("nested buffered setter property assigned without 
authorization ?", result.inner.getRole());
+    }
+
+    public void testBufferedBeanValuedSetterChildrenAuthorizedAtOwnDepth() 
throws Exception {
+        // The buffered read goes through the final 
SettableBeanProperty.deserialize(), so the
+        // nested bean's members must still be authorized under the property's 
own prefix.
+        bind((path, t, a) -> "inner".equals(path) || "inner.name".equals(path)
+                || "inner.address".equals(path) || 
"inner.address.city".equals(path), new CreatorAddressHolder(null));
+        CreatorAddressHolder result = mapper.readValue(
+                
"{\"inner\":{\"address\":{\"city\":\"Warsaw\",\"zip\":\"00-001\"},\"name\":\"alice\"}}",
+                CreatorAddressHolder.class);
+        assertEquals("alice", result.inner.name);
+        assertNotNull("buffered nested bean dropped ?", 
result.inner.getAddress());
+        assertEquals("nested member checked at the wrong depth ?", "Warsaw", 
result.inner.getAddress().city);
+        assertNull(result.inner.getAddress().zip);
+    }
+
+    public void 
testBeanValuedSetterAfterCreatorParamChildrenAuthorizedAtOwnDepth() throws 
Exception {
+        // Control: the direct path for the same property and authorizer.
+        bind((path, t, a) -> "inner".equals(path) || "inner.name".equals(path)
+                || "inner.address".equals(path) || 
"inner.address.city".equals(path), new CreatorAddressHolder(null));
+        CreatorAddressHolder result = mapper.readValue(
+                
"{\"inner\":{\"name\":\"alice\",\"address\":{\"city\":\"Warsaw\",\"zip\":\"00-001\"}}}",
+                CreatorAddressHolder.class);
+        assertEquals("alice", result.inner.name);
+        assertEquals("Warsaw", result.inner.getAddress().city);
+        assertNull(result.inner.getAddress().zip);
+    }
+
+    public void testSetterlessCollectionElementsAuthorizedAtOwnDepth() throws 
Exception {
+        // A collection getter without a setter is deserialized in place 
through the three-argument
+        // deserialize(); element members must be checked under items[0], not 
under the parent.
+        bind((path, t, a) -> "order".equals(path) || 
"order.items".equals(path) || "order.name".equals(path),
+                new OrderHolder());
+        OrderHolder result = 
mapper.readValue("{\"order\":{\"items\":[{\"name\":\"x\"}]}}", 
OrderHolder.class);
+        assertEquals(1, result.order.getItems().size());
+        assertNull("element member authorized by the parent's sibling grant 
?", result.order.getItems().get(0).name);
+    }
+
+    public void testPolymorphicPropertyMembersAuthorizedAtOwnDepth() throws 
Exception {
+        // A @JsonTypeInfo property is deserialized through 
deserializeWithType(); the subtype's
+        // members must be checked under pet, not against the enclosing bean.
+        bind((path, t, a) -> "pet".equals(path) || "owner".equals(path), new 
Kennel());
+        Kennel result = 
mapper.readValue("{\"pet\":{\"@type\":\"dog\",\"owner\":\"alice\"}}", 
Kennel.class);
+        assertTrue(result.pet instanceof Dog);
+        assertNull("subtype member authorized by the enclosing bean's sibling 
grant ?", ((Dog) result.pet).owner);
+    }
+
+    public void testNoContext_passThroughBufferedSetter() throws Exception {
+        CreatorWithSetter result = 
mapper.readValue("{\"role\":\"admin\",\"name\":\"alice\"}", 
CreatorWithSetter.class);
+        assertEquals("alice", result.name);
+        assertEquals("admin", result.getRole());
+    }
+
+    public void testNoContext_passThroughSetterlessCollection() throws 
Exception {
+        OrderHolder result = 
mapper.readValue("{\"order\":{\"items\":[{\"name\":\"x\"}]}}", 
OrderHolder.class);
+        assertEquals("x", result.order.getItems().get(0).name);
+    }
+
+    public void testNoContext_passThroughPolymorphicProperty() throws 
Exception {
+        Kennel result = 
mapper.readValue("{\"pet\":{\"@type\":\"dog\",\"owner\":\"alice\"}}", 
Kennel.class);
+        assertEquals("alice", ((Dog) result.pet).owner);
+    }
+
+    public void testBufferedPolymorphicSetterIsAuthorized() throws Exception {
+        // A polymorphic setter buffered before the creator parameter goes 
through deserializeWithType().
+        bind((path, t, a) -> "name".equals(path), new CreatorWithPet(""));
+        CreatorWithPet result = mapper.readValue(
+                
"{\"pet\":{\"@type\":\"dog\",\"owner\":\"alice\"},\"name\":\"alice\"}", 
CreatorWithPet.class);
+        assertEquals("alice", result.name);
+        assertNull("buffered polymorphic setter assigned without authorization 
?", result.getPet());
+    }
+
+    public void testMergeIntoExistingValueIsAuthorized() throws Exception {
+        // @JsonMerge into a non-null value deserializes in place through the 
three-argument
+        // deserialize(); an unauthorized property must leave the existing 
value untouched.
+        bind((path, t, a) -> "name".equals(path), new MergingBean());
+        MergingBean result = 
mapper.readValue("{\"name\":\"alice\",\"address\":{\"city\":\"Warsaw\"}}", 
MergingBean.class);
+        assertEquals("alice", result.name);
+        assertNull("merged into an unauthorized property ?", 
result.address.city);
+    }
+
+    public void testBufferedSetterInsideDynamicKeyScopeIsAuthorizedByDepth() 
throws Exception {
+        // Inside a dynamic-key scope the buffered path must consult the same 
depth rule as the
+        // direct path, not the annotation authorizer (which rejects 
everything here).
+        ObjectMapper enforcingMapper = enforcingMapper();
+        bind((path, t, a) -> false, new DynamicDepthTwoCreatorAnySetterBean());
+        DynamicDepthTwoCreatorAnySetterBean result = enforcingMapper.readValue(
+                "{\"home\":{\"role\":\"admin\",\"name\":\"alice\"}}", 
DynamicDepthTwoCreatorAnySetterBean.class);
+        assertEquals("alice", result.values.get("home").name);
+        assertEquals("admin", result.values.get("home").getRole());
+    }
+
     public void testCreatorPropertyEntirelyRejected_dropsWholeSubtree() throws 
Exception {
         // "inner" itself is never authorized -- the whole nested 
creator-bound object must be
         // dropped, matching how a rejected non-creator nested bean property 
behaves (see
@@ -555,6 +682,115 @@ public class ParameterAuthorizingModuleTest extends 
TestCase {
         public RecordAddress recordAddress;
     }
 
+    public static class CreatorWithSetter {
+        public final String name;
+        private String role;
+
+        @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+        public CreatorWithSetter(@JsonProperty("name") String name) {
+            this.name = name;
+        }
+
+        public String getRole() {
+            return role;
+        }
+
+        public void setRole(String role) {
+            this.role = role;
+        }
+    }
+
+    public static class CreatorWithAddress {
+        public final String name;
+        private Address address;
+
+        @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+        public CreatorWithAddress(@JsonProperty("name") String name) {
+            this.name = name;
+        }
+
+        public Address getAddress() {
+            return address;
+        }
+
+        public void setAddress(Address address) {
+            this.address = address;
+        }
+    }
+
+    public static class CreatorAddressHolder {
+        public final CreatorWithAddress inner;
+
+        @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+        public CreatorAddressHolder(@JsonProperty("inner") CreatorWithAddress 
inner) {
+            this.inner = inner;
+        }
+    }
+
+    public static class OrderHolder {
+        public Order order;
+    }
+
+    public static class Order {
+        public String name;
+        private final java.util.List<OrderItem> itemList = new 
java.util.ArrayList<>();
+
+        public java.util.List<OrderItem> getItems() {
+            return itemList;
+        }
+    }
+
+    public static class OrderItem {
+        public String name;
+    }
+
+    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "@type")
+    @JsonSubTypes(@JsonSubTypes.Type(value = Dog.class, name = "dog"))
+    public abstract static class Animal {
+    }
+
+    public static class Dog extends Animal {
+        public String owner;
+    }
+
+    public static class Kennel {
+        public Animal pet;
+        public String owner;
+    }
+
+    public static class CreatorWithPet {
+        public final String name;
+        private Animal pet;
+
+        @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+        public CreatorWithPet(@JsonProperty("name") String name) {
+            this.name = name;
+        }
+
+        public Animal getPet() {
+            return pet;
+        }
+
+        public void setPet(Animal pet) {
+            this.pet = pet;
+        }
+    }
+
+    public static class MergingBean {
+        public String name;
+        @JsonMerge
+        public Address address = new Address();
+    }
+
+    public static class CreatorHolder {
+        public final CreatorWithSetter inner;
+
+        @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+        public CreatorHolder(@JsonProperty("inner") CreatorWithSetter inner) {
+            this.inner = inner;
+        }
+    }
+
     public static class Address {
         public String city;
         public String zip;
@@ -626,6 +862,16 @@ public class ParameterAuthorizingModuleTest extends 
TestCase {
         }
     }
 
+    public static class DynamicDepthTwoCreatorAnySetterBean {
+        public final Map<String, CreatorWithSetter> values = new 
LinkedHashMap<>();
+
+        @JsonAnySetter
+        @StrutsParameter(allowDynamicKeys = true, depth = 2)
+        public void put(String name, CreatorWithSetter value) {
+            values.put(name, value);
+        }
+    }
+
     public static class DynamicDepthTwoAnySetterBean {
         public final Map<String, Address> values = new LinkedHashMap<>();
 

Reply via email to