From a209cc7930b422cd475e31c2950a876169948106 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Fri, 11 Sep 2026 15:58:58 +0200 Subject: [PATCH 1/2] 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) --- .../AuthorizingSettableBeanProperty.java | 74 ++++--- .../jackson/AuthorizingValueDeserializer.java | 81 ++++++-- .../ParameterAuthorizingModuleTest.java | 187 ++++++++++++++++++ 3 files changed, 292 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 14c3bcec72..a3365a8ff0 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 @@ 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 @@ * skipped via {@link JsonParser#skipChildren()}, so any nested object graph is never instantiated * and setter side effects on unauthorized properties never fire. * - *

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.

+ *

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.

* *

When {@link ParameterAuthorizationContext#isActive()} is {@code false}, this wrapper is a * straight pass-through to the delegate — no overhead for default-config requests.

@@ -61,17 +59,17 @@ protected SettableBeanProperty withDelegate(SettableBeanProperty d) { } /** - * 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 void deserializeAndSet(JsonParser p, DeserializationContext ctxt, Object 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 Object deserializeSetAndReturn(JsonParser p, DeserializationContext ctxt, 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 fc9532960c..a30cfe8ef0 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 @@ 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 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOEx 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 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOEx } } + @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 61ee26eafa..ccee59d44a 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 @@ -21,6 +21,8 @@ import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonCreator; 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 +436,96 @@ public void testConstructorPropertiesAuthorizedByPath() throws Exception { 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 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 +647,91 @@ public static class Person { 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 itemList = new java.util.ArrayList<>(); + + public java.util.List 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 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 +803,16 @@ public void put(String name, Address value) { } } + public static class DynamicDepthTwoCreatorAnySetterBean { + public final Map 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 values = new LinkedHashMap<>(); From 306322dc51a5d5ece322d4c793ebd9c7931e6012 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Sat, 12 Sep 2026 09:25:43 +0200 Subject: [PATCH 2/2] 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) --- .../ParameterAuthorizingModuleTest.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) 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 ccee59d44a..0cae3eabe0 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,6 +20,7 @@ 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; @@ -515,6 +516,40 @@ public void testPolymorphicPropertyMembersAuthorizedAtOwnDepth() throws Exceptio 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). @@ -723,6 +758,30 @@ public static class Kennel { 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;