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")

Reply via email to