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 6b246aee4 WW-5727 fix(rest): authorize the @JsonIdentityInfo id
property (#1943)
6b246aee4 is described below
commit 6b246aee4abb7f8ccb08cd12871c7db09ed4c550
Author: Lukasz Lenart <[email protected]>
AuthorDate: Tue Sep 15 08:23:10 2026 +0200
WW-5727 fix(rest): authorize the @JsonIdentityInfo id property (#1943)
On a type whose @JsonIdentityInfo uses a property-based generator,
BeanDeserializerFactory builds the ObjectIdReader before the
deserializer modifiers run and captures the id property as it was
then. ParameterAuthorizingModule then wraps every property in the
builder, but the ObjectIdValueProperty Jackson adds at build time
assigns the id through the reader's captured property, so the wrapper
is never consulted and the id binds without a check.
After wrapping, the module now rebuilds the reader with
ObjectIdReader.construct around a wrapped id property, keeping the id
type, property name, generator, deserializer and resolver. The id is
then assigned through the wrapper's setAndReturn and authorized like
any other property. Sequence-style generators carry no id property and
are left alone.
The wrapper's set and setAndReturn are no-ops over a creator property.
Jackson skips the post-construction write of a creator-bound id itself
by an instanceof CreatorProperty check the wrapper hides, and a record
has no setter to write through, so wrapping the id plainly broke every
record with a property-based id - through the builder's reader and
through the one createContextual builds for a @JsonIdentityInfo placed
on the referencing property. Leaving the reader alone for creator ids
was not an option either: a repeated id key after construction wrote
the creator property's fallback field unchecked. That repeated key is
now dropped where stock Jackson would push it through the fallback
field; the creator parameter is authorized through its own value
deserializer.
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../jackson/AuthorizingSettableBeanProperty.java | 25 ++++--
.../jackson/ParameterAuthorizingModule.java | 20 +++++
.../jackson/ParameterAuthorizingModuleTest.java | 93 ++++++++++++++++++++++
3 files changed, 133 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 f017ec60a..82a885cb7 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
@@ -21,6 +21,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.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;
@@ -41,9 +42,10 @@ 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. 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>
+ * straight pass-through to the delegate — no overhead for default-config
requests — except that a
+ * creator parameter is never written after construction (see {@link #set}),
and a polymorphic
+ * property is never merged in place, a decision taken when the deserializer
is built, see
+ * {@link AuthorizingValueDeserializer#supportsUpdate}.</p>
*
* @since 7.2.0
*/
@@ -128,19 +130,32 @@ public class AuthorizingSettableBeanProperty extends
SettableBeanProperty.Delega
@Override
public void set(Object instance, Object value) throws IOException {
- if (isAuthorizedForSet(instance)) {
+ if (!assignedByCreator() && isAuthorizedForSet(instance)) {
delegate.set(instance, value);
}
}
@Override
public Object setAndReturn(Object instance, Object value) throws
IOException {
- if (isAuthorizedForSet(instance)) {
+ if (!assignedByCreator() && isAuthorizedForSet(instance)) {
return delegate.setAndReturn(instance, value);
}
return instance;
}
+ /**
+ * A creator parameter is authorized and assigned through its value
deserializer. Jackson reaches
+ * {@link #set} on it only after construction: for the object id write it
skips itself by an
+ * {@code instanceof CreatorProperty} check this wrapper hides (a record
has no setter to write
+ * through), and for a key repeated after construction, which stock
Jackson pushes through the
+ * creator property's fallback field. The wrapper cannot tell the two
apart, so the creator's
+ * value stands in both cases, whether or not a context is bound — the one
place this wrapper is
+ * not a pass-through.
+ */
+ private boolean assignedByCreator() {
+ return delegate instanceof CreatorProperty;
+ }
+
/**
* Guards the already-materialized assignment path: Jackson buffers
non-creator properties seen
* before the last creator parameter and assigns them after construction
via
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModule.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModule.java
index d7aa5d262..fb9fc3f21 100644
---
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModule.java
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/ParameterAuthorizingModule.java
@@ -25,6 +25,7 @@ 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.deser.SettableBeanProperty;
+import com.fasterxml.jackson.databind.deser.impl.ObjectIdReader;
import com.fasterxml.jackson.databind.introspect.AnnotatedField;
import com.fasterxml.jackson.databind.introspect.AnnotatedMember;
import com.fasterxml.jackson.databind.introspect.AnnotatedMethod;
@@ -75,6 +76,7 @@ public class ParameterAuthorizingModule extends SimpleModule {
builder.addOrReplaceProperty(
new AuthorizingSettableBeanProperty(original,
memberNameOf(original)), true);
}
+ authorizeObjectIdProperty(builder);
if
(ParameterAuthorizingModule.this.requireAnySetterAnnotations) {
SettableAnyProperty anySetter = builder.getAnySetter();
if (anySetter != null && !(anySetter instanceof
AuthorizingSettableAnyProperty)) {
@@ -97,6 +99,24 @@ public class ParameterAuthorizingModule extends SimpleModule
{
});
}
+ /**
+ * Jackson builds the {@code ObjectIdReader} for a property-based {@code
@JsonIdentityInfo} before
+ * the deserializer modifiers run, capturing the id property as it was
then, and the
+ * {@code ObjectIdValueProperty} it adds at build time assigns the id
through that captured
+ * property rather than through the builder's. Rebuild the reader around a
wrapped one.
+ */
+ private static void authorizeObjectIdProperty(BeanDeserializerBuilder
builder) {
+ ObjectIdReader reader = builder.getObjectIdReader();
+ if (reader == null || reader.idProperty == null
+ || reader.idProperty instanceof
AuthorizingSettableBeanProperty) {
+ return;
+ }
+ SettableBeanProperty idProperty = new AuthorizingSettableBeanProperty(
+ reader.idProperty, memberNameOf(reader.idProperty));
+ builder.setObjectIdReader(ObjectIdReader.construct(reader.getIdType(),
reader.propertyName,
+ reader.generator, reader.getDeserializer(), idProperty,
reader.resolver));
+ }
+
/**
* The bean property name {@code StrutsParameterAuthorizer} resolves to
the member Jackson will
* invoke for this property: the field itself, or the property a
one-argument {@code set} or
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 68c6e1b0d..3ae4dd0ed 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
@@ -640,6 +640,69 @@ public class ParameterAuthorizingModuleTest extends
TestCase {
}
}
+ public void testObjectIdPropertyIsAuthorized() throws Exception {
+ // Jackson captures the id property in the ObjectIdReader before the
module wraps it, and
+ // ObjectIdValueProperty assigns the id through that captured property.
+ bind((path, t, a) -> "name".equals(path), new PropertyIdentified());
+ PropertyIdentified result =
mapper.readValue("{\"id\":7,\"name\":\"alice\"}", PropertyIdentified.class);
+ assertEquals("alice", result.name);
+ assertEquals("id assigned through the ObjectIdReader without
authorization ?", 0, result.id);
+ }
+
+ public void testObjectIdPropertyAssignedWhenAuthorized() throws Exception {
+ bind((path, t, a) -> "id".equals(path), new PropertyIdentified());
+ PropertyIdentified result =
mapper.readValue("{\"id\":7,\"name\":\"alice\"}", PropertyIdentified.class);
+ assertEquals(7, result.id);
+ assertNull(result.name);
+ }
+
+ public void testCreatorBoundObjectIdIsAssignedByTheCreatorOnly() throws
Exception {
+ // Jackson skips the post-construction write of a creator-bound id
(records have no setter
+ // for it); the wrapper must keep that skip and leave the id to the
authorized creator path.
+ bind((path, t, a) -> true, new IdentifiedRecord(0, null));
+ IdentifiedRecord result =
mapper.readValue("{\"id\":7,\"name\":\"alice\"}", IdentifiedRecord.class);
+ assertEquals(7, result.id());
+ assertEquals("alice", result.name());
+ }
+
+ public void testNoContext_passThroughCreatorBoundObjectId() throws
Exception {
+ IdentifiedRecord result =
mapper.readValue("{\"id\":7,\"name\":\"alice\"}", IdentifiedRecord.class);
+ assertEquals(7, result.id());
+ }
+
+ public void testCreatorBoundObjectIdRejectedAtTheCreator() throws
Exception {
+ bind((path, t, a) -> "name".equals(path), new IdentifiedRecord(0,
null));
+ IdentifiedRecord result =
mapper.readValue("{\"id\":7,\"name\":\"alice\"}", IdentifiedRecord.class);
+ assertEquals("alice", result.name());
+ assertEquals(0, result.id());
+ }
+
+ public void
testNoContext_passThroughCreatorBoundObjectIdDeclaredOnTheReferencingProperty()
throws Exception {
+ // A per-property @JsonIdentityInfo builds its reader in
createContextual from the already
+ // wrapped property; the creator-bound skip must hold there as well.
+ IdentifiedRecordHolder result =
mapper.readValue("{\"rec\":{\"id\":7,\"name\":\"alice\"}}",
+ IdentifiedRecordHolder.class);
+ assertEquals(7, result.rec.id());
+ }
+
+ public void
testCreatorBoundObjectIdDeclaredOnTheReferencingPropertyIsAuthorized() throws
Exception {
+ bind((path, t, a) -> "rec".equals(path) || "rec.name".equals(path),
new IdentifiedRecordHolder());
+ IdentifiedRecordHolder result =
mapper.readValue("{\"rec\":{\"id\":7,\"name\":\"alice\"}}",
+ IdentifiedRecordHolder.class);
+ assertEquals("alice", result.rec.name());
+ assertEquals(0, result.rec.id());
+ }
+
+ public void
testCreatorBoundObjectIdRepeatedAfterConstructionIsNotAssigned() throws
Exception {
+ // Stock Jackson pushes the repeated key through the creator
property's fallback field; the
+ // wrapper cannot tell that write from the one Jackson skips itself,
so the creator's value stays.
+ bind((path, t, a) -> true, new IdentifiedFinalField(0, null));
+ IdentifiedFinalField result =
mapper.readValue("{\"id\":1,\"name\":\"alice\",\"id\":7}",
+ IdentifiedFinalField.class);
+ assertEquals("alice", result.name);
+ assertEquals("repeated id written through the creator property's
fallback field ?", 1, result.id);
+ }
+
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).
@@ -878,6 +941,36 @@ public class ParameterAuthorizingModuleTest extends
TestCase {
public String owner;
}
+ @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class,
property = "id")
+ public static class PropertyIdentified {
+ public int id;
+ public String name;
+ }
+
+ @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class,
property = "id")
+ public record IdentifiedRecord(int id, String name) {
+ }
+
+ public record PlainRecord(int id, String name) {
+ }
+
+ public static class IdentifiedRecordHolder {
+ @JsonIdentityInfo(generator =
ObjectIdGenerators.PropertyGenerator.class, property = "id")
+ public PlainRecord rec;
+ }
+
+ @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class,
property = "id")
+ public static class IdentifiedFinalField {
+ public final int id;
+ public String name;
+
+ @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+ public IdentifiedFinalField(@JsonProperty("id") int id,
@JsonProperty("name") String name) {
+ this.id = id;
+ this.name = name;
+ }
+ }
+
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "@type")
@JsonSubTypes(@JsonSubTypes.Type(value = IdentifiedDog.class, name =
"dog"))
@JsonIdentityInfo(generator =
ObjectIdGenerators.IntSequenceGenerator.class, property = "@id")