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 08b6fbdde WW-5726 fix(rest): decline the in-place merge of a 
polymorphic REST body property (#1941)
08b6fbdde is described below

commit 08b6fbddecf5433e4420c7aaaa268e5e5e8db7c2
Author: Lukasz Lenart <[email protected]>
AuthorDate: Tue Sep 15 07:07:58 2026 +0200

    WW-5726 fix(rest): decline the in-place merge of a polymorphic REST body 
property (#1941)
    
    A property that is mergeable (@JsonMerge or merge by configuration),
    has a value type deserializer (@JsonTypeInfo) and a non-null initial
    value is deserialized by Jackson past both authorizing wrappers.
    MergingSettableBeanProperty is built by BeanDeserializerBase.resolve()
    around the already-wrapped property and calls the final
    SettableBeanProperty#deserializeWith, which resolves a fresh
    deserializer for the existing value's class and deserializes into it
    in place, so neither deserializeAndSet nor AuthorizingValueDeserializer
    runs and set() is never called. The property binds unchecked and its
    members are authorized as the enclosing bean's own members.
    
    AuthorizingValueDeserializer now carries the property's value type
    deserializer and answers supportsUpdate() with FALSE when one is
    present. resolve() asks the value deserializer before it builds the
    merging wrapper, through whichever property wrapper it has put around
    the authorizing one by then, so the property stays on the ordinary
    authorized path: it is checked itself, its members under its own
    prefix, and the value is replaced rather than merged. Under Jackson's
    default IGNORE_MERGE_FOR_UNMERGEABLE the declined merge is ignored; an
    application that disabled the feature gets Jackson's bad-definition
    report. A non-polymorphic merge keeps merging; WW-5725 covers that
    path. A WARN at resolve time makes the disabled merge visible, since
    the body now has to carry the type id.
    
    Hiding the merge info from the wrapper's getMetadata() was tried first
    and rejected: ManagedReferenceProperty and ObjectIdReferenceProperty
    copy the metadata field before resolve() consults it, so a
    @JsonManagedReference or @JsonIdentityInfo on the polymorphic type
    kept the bypass open. SettableBeanProperty has no withMetadata, so the
    merge cannot be stripped from the definition in updateBuilder either,
    and refusing the definition outright would take the whole bean type
    down for applications that never enabled requireAnnotations, since the
    module is registered on every Jackson handler. Intercepting the typed
    merge from RedactionAwareDeserializer#createContextual was rejected as
    the ticket anticipated: the same call serves the ordinary resolution.
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../jackson/AuthorizingSettableBeanProperty.java   |  9 +-
 .../jackson/AuthorizingValueDeserializer.java      | 28 ++++++-
 .../jackson/ParameterAuthorizingModuleTest.java    | 96 ++++++++++++++++++++++
 3 files changed, 128 insertions(+), 5 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 0e5c8ab89..f017ec60a 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
@@ -41,7 +41,9 @@ import java.io.IOException;
  * {@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>
+ * straight pass-through to the delegate — no overhead for default-config 
requests. The one decision
+ * taken regardless is made when the deserializer is built, not per request: a 
polymorphic property
+ * is never merged in place, see {@link 
AuthorizingValueDeserializer#supportsUpdate}.</p>
  *
  * @since 7.2.0
  */
@@ -79,13 +81,14 @@ public class AuthorizingSettableBeanProperty extends 
SettableBeanProperty.Delega
      * 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.
+     * property; it owns the path push for nested members on both the direct 
and the buffered path,
+     * and refuses the in-place merge of a polymorphic value that would 
otherwise skip both.
      */
     @Override
     public SettableBeanProperty withValueDeserializer(JsonDeserializer<?> 
deser) {
         JsonDeserializer<?> effective = deser;
         if (!(deser instanceof AuthorizingValueDeserializer)) {
-            effective = new AuthorizingValueDeserializer(deser, memberName, 
getType());
+            effective = new AuthorizingValueDeserializer(deser, memberName, 
getType(), getValueTypeDeserializer());
         }
         return _with(delegate.withValueDeserializer(effective));
     }
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 a30cfe8ef..122017f4e 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
@@ -19,6 +19,7 @@
 package org.apache.struts2.rest.handler.jackson;
 
 import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationConfig;
 import com.fasterxml.jackson.databind.DeserializationContext;
 import com.fasterxml.jackson.databind.JavaType;
 import com.fasterxml.jackson.databind.JsonDeserializer;
@@ -44,16 +45,39 @@ final class AuthorizingValueDeserializer extends 
DelegatingDeserializer {
 
     private final String propertyName;
     private final JavaType propertyType;
+    private final transient TypeDeserializer valueTypeDeserializer;
 
-    AuthorizingValueDeserializer(JsonDeserializer<?> delegate, String 
propertyName, JavaType propertyType) {
+    AuthorizingValueDeserializer(JsonDeserializer<?> delegate, String 
propertyName, JavaType propertyType,
+                                 TypeDeserializer valueTypeDeserializer) {
         super(delegate);
         this.propertyName = propertyName;
         this.propertyType = propertyType;
+        this.valueTypeDeserializer = valueTypeDeserializer;
     }
 
     @Override
     protected JsonDeserializer<?> newDelegatingInstance(JsonDeserializer<?> 
newDelegatee) {
-        return new AuthorizingValueDeserializer(newDelegatee, propertyName, 
propertyType);
+        return new AuthorizingValueDeserializer(newDelegatee, propertyName, 
propertyType, valueTypeDeserializer);
+    }
+
+    /**
+     * Merging into a non-null polymorphic value is Jackson's one path past 
both wrappers: the
+     * {@code final} {@code SettableBeanProperty#deserializeWith} deserializes 
in place through a
+     * deserializer resolved for the existing value's class. {@code 
BeanDeserializerBase.resolve()}
+     * asks the value deserializer first, whichever property wrapper it has 
built by then, so
+     * declining keeps the property on the ordinary authorized path: the value 
is replaced and the
+     * body must carry its type id. Jackson ignores the declined merge under
+     * {@code MapperFeature.IGNORE_MERGE_FOR_UNMERGEABLE} and reports a bad 
definition otherwise.
+     */
+    @Override
+    public Boolean supportsUpdate(DeserializationConfig config) {
+        if (valueTypeDeserializer == null) {
+            return super.supportsUpdate(config);
+        }
+        LOG.warn("Merge disabled for polymorphic REST body property [{}]: an 
in-place polymorphic merge"
+                + " cannot be authorized, so the value is replaced and the 
body must carry its type id",
+                propertyName);
+        return Boolean.FALSE;
     }
 
     @Override
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 bb9a6f99b..68c6e1b0d 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
@@ -19,16 +19,21 @@
 package org.apache.struts2.rest.handler.jackson;
 
 import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.fasterxml.jackson.annotation.JsonBackReference;
 import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIdentityInfo;
+import com.fasterxml.jackson.annotation.JsonManagedReference;
 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.annotation.ObjectIdGenerators;
 import com.fasterxml.jackson.core.JsonParser;
 import com.fasterxml.jackson.databind.BeanDescription;
 import com.fasterxml.jackson.databind.DeserializationConfig;
 import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.MapperFeature;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.PropertyNamingStrategies;
 import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
@@ -36,6 +41,7 @@ import 
com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
 import com.fasterxml.jackson.databind.deser.BeanDeserializerBuilder;
 import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;
 import com.fasterxml.jackson.databind.deser.SettableAnyProperty;
+import com.fasterxml.jackson.databind.exc.InvalidDefinitionException;
 import com.fasterxml.jackson.databind.module.SimpleModule;
 import com.fasterxml.jackson.databind.util.TokenBuffer;
 import com.fasterxml.jackson.dataformat.xml.XmlMapper;
@@ -584,6 +590,56 @@ public class ParameterAuthorizingModuleTest extends 
TestCase {
         assertNull("merged into an unauthorized property ?", 
result.address.city);
     }
 
+    public void testMergedPolymorphicPropertyWithInitialValueIsAuthorized() 
throws Exception {
+        // @JsonMerge into a non-null polymorphic value would otherwise 
deserialize in place through
+        // Jackson's typed merge, which reaches neither wrapper: the property 
is never authorized and
+        // the subtype's members are checked against the enclosing bean's 
grants.
+        bind((path, t, a) -> "owner".equals(path), new MergingKennel());
+        MergingKennel result = 
mapper.readValue("{\"pet\":{\"owner\":\"alice\"}}", MergingKennel.class);
+        assertNull("subtype member assigned through an unauthorized merged 
property ?",
+                ((Dog) result.pet).owner);
+    }
+
+    public void 
testMergedPolymorphicPropertyIsReplacedThroughTheAuthorizedPath() throws 
Exception {
+        MergingKennel bean = new MergingKennel();
+        Animal initial = bean.pet;
+        bind((path, t, a) -> "pet".equals(path) || "pet.owner".equals(path), 
bean);
+        
mapper.readerForUpdating(bean).readValue("{\"pet\":{\"@type\":\"dog\",\"owner\":\"alice\"}}");
+        assertEquals("alice", ((Dog) bean.pet).owner);
+        assertNotSame("merge must be disabled for a polymorphic property", 
initial, bean.pet);
+    }
+
+    public void 
testMergedPolymorphicPropertyBehindObjectIdWrapperIsAuthorized() throws 
Exception {
+        // @JsonIdentityInfo makes resolve() copy the property into an 
ObjectIdReferenceProperty
+        // before it decides on merging; the merge must still be refused there.
+        bind((path, t, a) -> "owner".equals(path), new 
MergingIdentifiedKennel());
+        MergingIdentifiedKennel result = 
mapper.readValue("{\"pet\":{\"owner\":\"alice\"}}",
+                MergingIdentifiedKennel.class);
+        assertNull("subtype member assigned through an unauthorized merged 
property ?",
+                ((IdentifiedDog) result.pet).owner);
+    }
+
+    public void 
testMergedPolymorphicPropertyBehindManagedReferenceWrapperIsAuthorized() throws 
Exception {
+        bind((path, t, a) -> "owner".equals(path), new MergingManagedKennel());
+        MergingManagedKennel result = 
mapper.readValue("{\"pet\":{\"owner\":\"alice\"}}",
+                MergingManagedKennel.class);
+        assertNull("subtype member assigned through an unauthorized merged 
property ?",
+                ((ManagedDog) result.pet).owner);
+    }
+
+    public void 
testMergedPolymorphicPropertyIsRefusedWhenUnmergeableIsNotIgnored() throws 
Exception {
+        ObjectMapper strict = new ObjectMapper()
+                .disable(MapperFeature.IGNORE_MERGE_FOR_UNMERGEABLE)
+                .registerModule(new ParameterAuthorizingModule());
+        bind((path, t, a) -> true, new MergingKennel());
+        try {
+            
strict.readValue("{\"pet\":{\"@type\":\"dog\",\"owner\":\"alice\"}}", 
MergingKennel.class);
+            fail("a polymorphic merge the module cannot authorize must be 
reported as a bad definition");
+        } catch (InvalidDefinitionException expected) {
+            assertTrue(expected.getMessage(), 
expected.getMessage().contains("cannot be merged"));
+        }
+    }
+
     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).
@@ -816,6 +872,46 @@ public class ParameterAuthorizingModuleTest extends 
TestCase {
         public Address address = new Address();
     }
 
+    public static class MergingKennel {
+        @JsonMerge
+        public Animal pet = new Dog();
+        public String owner;
+    }
+
+    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "@type")
+    @JsonSubTypes(@JsonSubTypes.Type(value = IdentifiedDog.class, name = 
"dog"))
+    @JsonIdentityInfo(generator = 
ObjectIdGenerators.IntSequenceGenerator.class, property = "@id")
+    public abstract static class IdentifiedAnimal {
+    }
+
+    public static class IdentifiedDog extends IdentifiedAnimal {
+        public String owner;
+    }
+
+    public static class MergingIdentifiedKennel {
+        @JsonMerge
+        public IdentifiedAnimal pet = new IdentifiedDog();
+        public String owner;
+    }
+
+    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "@type")
+    @JsonSubTypes(@JsonSubTypes.Type(value = ManagedDog.class, name = "dog"))
+    public abstract static class ManagedAnimal {
+        @JsonBackReference
+        public MergingManagedKennel kennel;
+    }
+
+    public static class ManagedDog extends ManagedAnimal {
+        public String owner;
+    }
+
+    public static class MergingManagedKennel {
+        @JsonMerge
+        @JsonManagedReference
+        public ManagedAnimal pet = new ManagedDog();
+        public String owner;
+    }
+
     public static class CreatorHolder {
         public final CreatorWithSetter inner;
 

Reply via email to