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 ec6f41b93 WW-5745 fix(rest): keep the read-time verdict for a 
forward-referenced property (#1944)
ec6f41b93 is described below

commit ec6f41b93c6167d99412d1cbc3fd513b74101d59
Author: Lukasz Lenart <[email protected]>
AuthorDate: Tue Sep 15 11:14:26 2026 +0200

    WW-5745 fix(rest): keep the read-time verdict for a forward-referenced 
property (#1944)
    
    When a property refers to an object by @JsonIdentityInfo id before that
    object has appeared in the body, Jackson defers the assignment:
    ObjectIdReferenceProperty registers a referring on the id and, once the
    object is deserialized, assigns it through the property's set() from
    wherever in the body the object turned up. AuthorizingSettableBeanProperty
    authorized that write with pathFor(memberName) against the path prefix
    and dynamic-key scope current at that moment, i.e. the target object's
    location rather than the referring property's. The property had already
    been authorized under its own path when it was read, so the second
    check could only reject: a correctly granted forward reference across
    nesting depths, or resolved inside an any-setter subtree, was dropped
    with a WARN naming the target's path.
    
    AuthorizingValueDeserializer now catches the UnresolvedForwardReference
    after the property has been authorized and, before rethrowing, records
    the verdict in AuthorizedForwardReferences against the awaited id and
    the member name. The wrapper's set() finds the entry by the object that
    id resolved to, the one it receives, and skips the second check; the
    deferred write is the read completing, not a new assignment to
    authorize. Keying by the id rather than by the referring bean covers a
    creator-bound referrer as well, which is not constructed yet when the
    reference is read and has its reference buffered until it is. The
    handlers already clear the module's request-scoped state after every
    read; AuthorizedForwardReferences is cleared with it, and
    ContentTypeInterceptor now clears that state once more when it unbinds
    the context, so a handler that registers the module on its own mapper
    without clearing cannot leave verdicts on the thread.
    
    Re-authorizing the deferred write under the recorded path was tried
    first and rejected in review: the dynamic-key scope active at bind time
    is not the one the property was read under, so a reference resolved
    inside an allowDynamicKeys subtree was still refused, and registering a
    referring on the id to carry the path duplicated every unresolved id in
    Jackson's error report.
    
    Collection and map element forward references are resolved inside their
    own deserializers and never reach the property wrapper; they are not
    affected.
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../struts2/rest/ContentTypeInterceptor.java       |   2 +
 .../jackson/AuthorizedForwardReferences.java       |  82 ++++++++++++++
 .../jackson/AuthorizingSettableBeanProperty.java   |  13 ++-
 .../jackson/AuthorizingValueDeserializer.java      |  20 ++++
 .../jackson/ParameterAuthorizingModule.java        |  13 ++-
 .../ContentTypeInterceptorIntegrationTest.java     |  45 +++++++-
 .../jackson/ForwardReferenceStateProbe.java        |  40 +++++++
 .../jackson/ParameterAuthorizingModuleTest.java    | 124 +++++++++++++++++++++
 8 files changed, 332 insertions(+), 7 deletions(-)

diff --git 
a/plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java
 
b/plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java
index e814e5dfc..8096152f2 100644
--- 
a/plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java
+++ 
b/plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java
@@ -26,6 +26,7 @@ import org.apache.struts2.interceptor.AbstractInterceptor;
 import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
 import org.apache.struts2.ServletActionContext;
 import org.apache.struts2.rest.handler.ContentTypeHandler;
+import org.apache.struts2.rest.handler.jackson.ParameterAuthorizingModule;
 import org.apache.commons.lang3.BooleanUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.logging.log4j.LogManager;
@@ -175,6 +176,7 @@ public class ContentTypeInterceptor extends 
AbstractInterceptor {
             handler.toObject(invocation, reader, target);
         } finally {
             
org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext.unbind();
+            ParameterAuthorizingModule.clearRequestState();
         }
     }
 
diff --git 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizedForwardReferences.java
 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizedForwardReferences.java
new file mode 100644
index 000000000..28eeea1ee
--- /dev/null
+++ 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizedForwardReferences.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.rest.handler.jackson;
+
+import com.fasterxml.jackson.databind.deser.impl.ReadableObjectId;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * Remembers which properties were authorized when read but assigned later: a 
forward reference by
+ * {@code @JsonIdentityInfo} id is written by Jackson through the property's 
{@code set} once the
+ * referenced object appears, wherever in the body that is. An entry is the id 
awaited and the
+ * member name, and the deferred write finds it by the object that id resolved 
to — the one it
+ * receives — so a referrer that is not constructed yet when the reference is 
read is covered too.
+ * The match asks the id's {@code ObjectIdResolver}; a custom resolver that 
does not hand back the
+ * object it was bound leaves the write on the check made where the object 
appeared.
+ */
+final class AuthorizedForwardReferences {
+
+    private static final ThreadLocal<List<Entry>> ENTRIES = new 
ThreadLocal<>();
+
+    private AuthorizedForwardReferences() {
+        // utility
+    }
+
+    static void expect(ReadableObjectId awaited, String memberName) {
+        List<Entry> entries = ENTRIES.get();
+        if (entries == null) {
+            entries = new ArrayList<>();
+            ENTRIES.set(entries);
+        }
+        entries.add(new Entry(awaited, memberName));
+    }
+
+    static boolean consume(Object resolved, String memberName) {
+        List<Entry> entries = ENTRIES.get();
+        if (entries == null || resolved == null) {
+            return false;
+        }
+        for (Iterator<Entry> it = entries.iterator(); it.hasNext(); ) {
+            Entry entry = it.next();
+            if (entry.memberName.equals(memberName) && entry.awaited.resolve() 
== resolved) {
+                it.remove();
+                if (entries.isEmpty()) {
+                    ENTRIES.remove();
+                }
+                return true;
+            }
+        }
+        return false;
+    }
+
+    static boolean isActive() {
+        List<Entry> entries = ENTRIES.get();
+        return entries != null && !entries.isEmpty();
+    }
+
+    static void clear() {
+        ENTRIES.remove();
+    }
+
+    private record Entry(ReadableObjectId awaited, String memberName) {
+    }
+}
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 82a885cb7..bd0f79c42 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
@@ -130,14 +130,14 @@ public class AuthorizingSettableBeanProperty extends 
SettableBeanProperty.Delega
 
     @Override
     public void set(Object instance, Object value) throws IOException {
-        if (!assignedByCreator() && isAuthorizedForSet(instance)) {
+        if (!assignedByCreator() && isAuthorizedForSet(instance, value)) {
             delegate.set(instance, value);
         }
     }
 
     @Override
     public Object setAndReturn(Object instance, Object value) throws 
IOException {
-        if (!assignedByCreator() && isAuthorizedForSet(instance)) {
+        if (!assignedByCreator() && isAuthorizedForSet(instance, value)) {
             return delegate.setAndReturn(instance, value);
         }
         return instance;
@@ -160,12 +160,17 @@ public class AuthorizingSettableBeanProperty extends 
SettableBeanProperty.Delega
      * 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}.
+     * {@link #deserializeAndSet}; and it assigns a forward-referenced object 
through {@code set()}
+     * once that object appears, a write whose verdict was taken when the 
property was read, see
+     * {@link AuthorizedForwardReferences}.
      */
-    private boolean isAuthorizedForSet(Object instance) {
+    private boolean isAuthorizedForSet(Object instance, Object value) {
         if (!ParameterAuthorizationContext.isActive()) {
             return true;
         }
+        if (AuthorizedForwardReferences.consume(value, memberName)) {
+            return true;
+        }
         String path = ParameterAuthorizationContext.pathFor(memberName);
         if (DynamicKeyAuthorizationContext.isAuthorized(path)) {
             return true;
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 122017f4e..2676c0e2d 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
@@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.DeserializationConfig;
 import com.fasterxml.jackson.databind.DeserializationContext;
 import com.fasterxml.jackson.databind.JavaType;
 import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.deser.UnresolvedForwardReference;
 import com.fasterxml.jackson.databind.deser.std.DelegatingDeserializer;
 import com.fasterxml.jackson.databind.jsontype.TypeDeserializer;
 import org.apache.logging.log4j.LogManager;
@@ -96,6 +97,8 @@ final class AuthorizingValueDeserializer extends 
DelegatingDeserializer {
         ParameterAuthorizationContext.pushPath(prefixForNested(path));
         try {
             return super.deserialize(p, ctxt);
+        } catch (UnresolvedForwardReference reference) {
+            throw authorizedForwardReference(reference);
         } finally {
             ParameterAuthorizationContext.popPath();
         }
@@ -131,11 +134,28 @@ final class AuthorizingValueDeserializer extends 
DelegatingDeserializer {
         ParameterAuthorizationContext.pushPath(prefixForNested(path));
         try {
             return super.deserializeWithType(p, ctxt, typeDeserializer);
+        } catch (UnresolvedForwardReference reference) {
+            throw authorizedForwardReference(reference);
         } finally {
             ParameterAuthorizationContext.popPath();
         }
     }
 
+    /**
+     * The property was authorized just now, but its value is an id whose 
object has not appeared
+     * yet: Jackson assigns it through the property's {@code set} once the 
object does, wherever in
+     * the body that is. That write is this read completing, so its verdict is 
recorded against the
+     * awaited id rather than taken again under whatever path and dynamic-key 
scope are current
+     * then. The aggregate exception Jackson raises at the end of a read for 
ids that never
+     * appeared carries no id and records nothing.
+     */
+    private UnresolvedForwardReference 
authorizedForwardReference(UnresolvedForwardReference reference) {
+        if (reference.getRoid() != null) {
+            AuthorizedForwardReferences.expect(reference.getRoid(), 
propertyName);
+        }
+        return reference;
+    }
+
     private boolean authorize(String path, JsonParser p) throws IOException {
         if (DynamicKeyAuthorizationContext.isAuthorized(path)) {
             return true;
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 fb9fc3f21..a308a039e 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
@@ -154,11 +154,22 @@ public class ParameterAuthorizingModule extends 
SimpleModule {
     }
 
     /**
-     * Clears request-scoped dynamic-key authorization state after a mapper 
read.
+     * Clears the request-scoped state this module keeps on the thread — the 
dynamic-key scopes of
+     * any-setters and the verdicts awaiting a forward reference — after a 
mapper read. A handler that
+     * registers this module on its own mapper must call it in a {@code 
finally} around every read;
+     * {@code ContentTypeInterceptor} clears the same state once more when it 
unbinds the context.
      *
      * @since 7.4.0
      */
     public void clearAuthorizationContext() {
+        clearRequestState();
+    }
+
+    /**
+     * @since 7.4.0
+     */
+    public static void clearRequestState() {
         DynamicKeyAuthorizationContext.clear();
+        AuthorizedForwardReferences.clear();
     }
 }
diff --git 
a/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
 
b/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
index 3cb03f0ba..f2a973596 100644
--- 
a/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
+++ 
b/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
@@ -35,10 +35,15 @@ import 
org.apache.struts2.ognl.DefaultOgnlExpressionCacheFactory;
 import org.apache.struts2.ognl.OgnlUtil;
 import org.apache.struts2.ognl.StrutsOgnlGuard;
 import org.apache.struts2.ognl.StrutsProxyCacheFactory;
+import org.apache.struts2.rest.handler.AuthorizationAwareContentTypeHandler;
+import org.apache.struts2.rest.handler.ContentTypeHandler;
 import org.apache.struts2.rest.handler.JacksonJsonHandler;
+import org.apache.struts2.rest.handler.jackson.ForwardReferenceStateProbe;
 import org.apache.struts2.util.StrutsProxyService;
 import org.springframework.mock.web.MockHttpServletRequest;
 
+import java.io.Reader;
+import java.io.Writer;
 import java.util.LinkedHashMap;
 import java.util.Map;
 
@@ -68,6 +73,12 @@ public class ContentTypeInterceptorIntegrationTest extends 
TestCase {
     }
 
     private void setupInterceptorWithAction(Object actionInstance, boolean 
requireAnySetterAnnotations) {
+        JacksonJsonHandler handler = new JacksonJsonHandler();
+        
handler.setAnySetterRequireAnnotations(Boolean.toString(requireAnySetterAnnotations));
+        setupInterceptorWithHandler(actionInstance, handler);
+    }
+
+    private void setupInterceptorWithHandler(Object actionInstance, 
ContentTypeHandler handler) {
         var ognlUtil = new OgnlUtil(
                 new DefaultOgnlExpressionCacheFactory<>("1000", 
LRU.toString()),
                 new DefaultOgnlBeanInfoCacheFactory<>("1000", LRU.toString()),
@@ -89,8 +100,6 @@ public class ContentTypeInterceptorIntegrationTest extends 
TestCase {
         mockActionInvocation.expectAndReturn("getAction", actionInstance);
         mockActionInvocation.expectAndReturn("getAction", actionInstance);
         mockActionInvocation.expectAndReturn("invoke", Action.SUCCESS);
-        JacksonJsonHandler handler = new JacksonJsonHandler();
-        
handler.setAnySetterRequireAnnotations(Boolean.toString(requireAnySetterAnnotations));
         mockSelector.expectAndReturn("getHandlerForRequest", new 
AnyConstraintMatcher() {
             @Override
             public boolean matches(Object[] args) { return true; }
@@ -199,6 +208,15 @@ public class ContentTypeInterceptorIntegrationTest extends 
TestCase {
         assertEquals("admin", anySetterAction.getValues().get("role"));
     }
 
+    public void testInterceptorClearsModuleRequestStateLeftByTheHandler() 
throws Exception {
+        // A handler that registers the module on its own mapper may not clear 
after its read; the
+        // interceptor must, since the state is keyed on this thread.
+        setupInterceptorWithHandler(action, new StatePlantingHandler());
+        runWithBody("{\"name\":\"alice\"}");
+        assertFalse("module request state must not outlive the interceptor's 
context",
+                ForwardReferenceStateProbe.isActive());
+    }
+
     public void testAnnotatedMemberRenamedOnTheWireIsApplied() throws 
Exception {
         RenamedPropertiesAction renamed = new RenamedPropertiesAction();
         setupInterceptorWithAction(renamed);
@@ -222,6 +240,29 @@ public class ContentTypeInterceptorIntegrationTest extends 
TestCase {
                 + " unannotated setAdmin", merged.admin());
     }
 
+    /** Stands in for a third-party handler that leaves the module's thread 
state behind. */
+    public static class StatePlantingHandler implements 
AuthorizationAwareContentTypeHandler {
+        @Override
+        public void toObject(ActionInvocation invocation, Reader in, Object 
target) {
+            ForwardReferenceStateProbe.plant();
+        }
+
+        @Override
+        public String fromObject(ActionInvocation invocation, Object obj, 
String resultCode, Writer stream) {
+            return null;
+        }
+
+        @Override
+        public String getContentType() {
+            return "application/json";
+        }
+
+        @Override
+        public String getExtension() {
+            return "json";
+        }
+    }
+
     // --- Test fixtures for new path verification ---
 
     /**
diff --git 
a/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ForwardReferenceStateProbe.java
 
b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ForwardReferenceStateProbe.java
new file mode 100644
index 000000000..8333dca44
--- /dev/null
+++ 
b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/jackson/ForwardReferenceStateProbe.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.rest.handler.jackson;
+
+import com.fasterxml.jackson.annotation.ObjectIdGenerator;
+import com.fasterxml.jackson.databind.deser.impl.ReadableObjectId;
+
+/**
+ * Lets a test outside this package plant and inspect a pending 
forward-reference verdict.
+ */
+public final class ForwardReferenceStateProbe {
+
+    private ForwardReferenceStateProbe() {
+    }
+
+    public static void plant() {
+        AuthorizedForwardReferences.expect(
+                new ReadableObjectId(new ObjectIdGenerator.IdKey(Object.class, 
null, "stale")), "stale");
+    }
+
+    public static boolean isActive() {
+        return AuthorizedForwardReferences.isActive();
+    }
+}
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 3ae4dd0ed..9c32a7f6e 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
@@ -28,6 +28,7 @@ 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.ObjectIdGenerator;
 import com.fasterxml.jackson.annotation.ObjectIdGenerators;
 import com.fasterxml.jackson.core.JsonParser;
 import com.fasterxml.jackson.databind.BeanDescription;
@@ -41,6 +42,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.deser.impl.ReadableObjectId;
 import com.fasterxml.jackson.databind.exc.InvalidDefinitionException;
 import com.fasterxml.jackson.databind.module.SimpleModule;
 import com.fasterxml.jackson.databind.util.TokenBuffer;
@@ -59,6 +61,7 @@ import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
 
 public class ParameterAuthorizingModuleTest extends TestCase {
@@ -74,6 +77,7 @@ public class ParameterAuthorizingModuleTest extends TestCase {
     protected void tearDown() {
         ParameterAuthorizationContext.unbind();
         DynamicKeyAuthorizationContext.clear();
+        AuthorizedForwardReferences.clear();
     }
 
     private void bind(ParameterAuthorizer authorizer, Object instance) {
@@ -703,6 +707,77 @@ public class ParameterAuthorizingModuleTest extends 
TestCase {
         assertEquals("repeated id written through the creator property's 
fallback field ?", 1, result.id);
     }
 
+    public void 
testForwardReferenceIsAuthorizedUnderTheReferringPropertyPath() throws 
Exception {
+        // Jackson assigns a forward reference when the referenced object 
appears, wherever that is;
+        // the deferred write must be checked under the referring property's 
own path, not the
+        // target's.
+        Set<String> granted = Set.of("people", "people[0].id", 
"people[0].name", "people[0].friend",
+                "boss", "boss.id", "boss.name");
+        bind((path, t, a) -> granted.contains(path), new Office());
+        Office result = mapper.readValue(
+                
"{\"people\":[{\"id\":1,\"name\":\"a\",\"friend\":2}],\"boss\":{\"id\":2,\"name\":\"b\"}}",
+                Office.class);
+        assertSame("forward reference rejected under the target's path ?", 
result.boss, result.people.get(0).friend);
+    }
+
+    public void testForwardReferenceRejectedUnderTheReferringPropertyPath() 
throws Exception {
+        Set<String> granted = Set.of("people", "people[0].id", 
"people[0].name",
+                "boss", "boss.id", "boss.name", "boss.friend");
+        bind((path, t, a) -> granted.contains(path), new Office());
+        Office result = mapper.readValue(
+                
"{\"people\":[{\"id\":1,\"name\":\"a\",\"friend\":2}],\"boss\":{\"id\":2,\"name\":\"b\"}}",
+                Office.class);
+        assertNull("forward reference authorized under the target's path ?", 
result.people.get(0).friend);
+        assertEquals("b", result.boss.name);
+    }
+
+    public void 
testForwardReferenceResolvedInsideDynamicKeyScopeKeepsTheReadTimeVerdict() 
throws Exception {
+        // The target lands in an any-setter subtree, so the deferred write 
runs while that dynamic
+        // scope is active; the verdict taken when the reference was read must 
stand.
+        ObjectMapper enforcingMapper = enforcingMapper();
+        Set<String> granted = Set.of("people", "people[0].id", 
"people[0].name", "people[0].friend");
+        bind((path, t, a) -> granted.contains(path), new DynamicOffice());
+        DynamicOffice result = enforcingMapper.readValue(
+                
"{\"people\":[{\"id\":1,\"name\":\"a\",\"friend\":2}],\"boss\":{\"id\":2,\"name\":\"b\"}}",
+                DynamicOffice.class);
+        assertSame("forward reference re-checked under the target's 
dynamic-key scope ?",
+                result.values.get("boss"), result.people.get(0).friend);
+    }
+
+    public void 
testForwardReferenceFromCreatorBoundReferrerIsAuthorizedUnderItsOwnPath() 
throws Exception {
+        // A creator-bound referrer is not constructed yet when its forward 
reference is read;
+        // Jackson buffers the reference and assigns it after construction.
+        Set<String> granted = Set.of("people", "people[0].id", 
"people[0].name", "people[0].friend",
+                "boss", "boss.id", "boss.name");
+        bind((path, t, a) -> granted.contains(path), new CreatorOffice());
+        CreatorOffice result = mapper.readValue(
+                
"{\"people\":[{\"id\":1,\"friend\":2}],\"boss\":{\"id\":2,\"name\":\"b\"}}",
+                CreatorOffice.class);
+        assertSame("forward reference rejected under the target's path ?", 
result.boss, result.people.get(0).getFriend());
+    }
+
+    public void testNoContext_passThroughForwardReference() throws Exception {
+        Office result = mapper.readValue(
+                
"{\"people\":[{\"id\":1,\"name\":\"a\",\"friend\":2}],\"boss\":{\"id\":2,\"name\":\"b\"}}",
+                Office.class);
+        assertSame(result.boss, result.people.get(0).friend);
+        assertFalse(AuthorizedForwardReferences.isActive());
+    }
+
+    public void 
testJacksonHandlerClearsAuthorizedForwardReferencesAfterReadFailure() throws 
Exception {
+        AuthorizedForwardReferences.expect(new ReadableObjectId(new 
ObjectIdGenerator.IdKey(Object.class, null, "stale")), "stale");
+        assertTrue(AuthorizedForwardReferences.isActive());
+
+        try {
+            new JacksonJsonHandler().toObject(null, new StringReader("{"), new 
Person());
+            fail("expected malformed JSON to fail");
+        } catch (Exception expected) {
+            // The handler's request-boundary cleanup must run even when 
Jackson aborts the read.
+        }
+
+        assertFalse(AuthorizedForwardReferences.isActive());
+    }
+
     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).
@@ -947,6 +1022,55 @@ public class ParameterAuthorizingModuleTest extends 
TestCase {
         public String name;
     }
 
+    @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, 
property = "id")
+    public static class Employee {
+        public int id;
+        public String name;
+        public Employee friend;
+    }
+
+    public static class Office {
+        public List<Employee> people;
+        public Employee boss;
+    }
+
+    @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, 
property = "id")
+    public static class CreatorEmployee {
+        public final int id;
+        public final String name;
+        private CreatorEmployee friend;
+
+        @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+        public CreatorEmployee(@JsonProperty("id") int id, 
@JsonProperty("name") String name) {
+            this.id = id;
+            this.name = name;
+        }
+
+        public CreatorEmployee getFriend() {
+            return friend;
+        }
+
+        public void setFriend(CreatorEmployee friend) {
+            this.friend = friend;
+        }
+    }
+
+    public static class CreatorOffice {
+        public List<CreatorEmployee> people;
+        public CreatorEmployee boss;
+    }
+
+    public static class DynamicOffice {
+        public List<Employee> people;
+        public final Map<String, Employee> values = new LinkedHashMap<>();
+
+        @JsonAnySetter
+        @StrutsParameter(allowDynamicKeys = true, depth = 3)
+        public void put(String name, Employee value) {
+            values.put(name, value);
+        }
+    }
+
     @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, 
property = "id")
     public record IdentifiedRecord(int id, String name) {
     }

Reply via email to