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) {
}