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;