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 6581dd404 WW-5746 fix(rest): authorize a bean-typed @JsonIdentityInfo
id's members under the id path (#1945)
6581dd404 is described below
commit 6581dd4049b7689a34654737e95bdb8c2fc7bd33
Author: Lukasz Lenart <[email protected]>
AuthorDate: Tue Sep 15 13:15:56 2026 +0200
WW-5746 fix(rest): authorize a bean-typed @JsonIdentityInfo id's members
under the id path (#1945)
The ObjectIdReader reads a property-based id through its own
deserializer, resolved by BeanDeserializerFactory for the id type, not
through the id property's value deserializer. Nothing pushes a path
prefix for it, so when the id type is a bean its members were authorized
at the enclosing bean's level - k instead of id.k - and a grant on a
same-named member of the enclosing bean authorized the write into the
id. The id property itself has been gated at id since WW-5727; this is
the path its members are checked under.
ParameterAuthorizingModule now rebuilds the reader around
ObjectIdPathDeserializer as well, which pushes the id property's path
around the delegate. It authorizes and redacts nothing: the id property
is checked when it is assigned. A redacted id member leaves the id bean
incomplete, so ids may collide and Jackson reports the conflict, and an
id bean that does not construct fails Jackson's binding; either fails
the read rather than binding a wrong id. A scalar id sees no
difference.
The rebuild is shared with RedactionAwareDeserializer.createContextual:
a @JsonIdentityInfo placed on the referring property makes Jackson build
a fresh reader there, after the modifier ran, with the bare root
deserializer for the id type, so the class-level rebuild alone left that
path as it was.
The reader also uses this deserializer for a reference; Jackson's
property-based generator never parses an object token as one, so only a
custom generator would see a reference by id structure, and it then
sits under the referring property's id as in the body. A creator-bound
bean with a bean-typed id is not covered by a test: Jackson reads such
an id twice and the creator parameter ends up empty regardless.
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../handler/jackson/ObjectIdPathDeserializer.java | 81 ++++++++++++++++++++++
.../jackson/ParameterAuthorizingModule.java | 30 +++++---
.../jackson/RedactionAwareDeserializer.java | 26 +++++++
.../jackson/ParameterAuthorizingModuleTest.java | 61 ++++++++++++++++
4 files changed, 190 insertions(+), 8 deletions(-)
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/ObjectIdPathDeserializer.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/ObjectIdPathDeserializer.java
new file mode 100644
index 000000000..f32a7a767
--- /dev/null
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/ObjectIdPathDeserializer.java
@@ -0,0 +1,81 @@
+/*
+ * 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.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.deser.std.DelegatingDeserializer;
+import com.fasterxml.jackson.databind.jsontype.TypeDeserializer;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
+
+import java.io.IOException;
+
+/**
+ * Puts the members of a bean-typed {@code @JsonIdentityInfo} id under the id
property's path. The
+ * {@code ObjectIdReader} reads the id through its own deserializer, not the
property's, so nothing
+ * pushes a prefix for it and its members would be checked as the enclosing
bean's. The reader uses
+ * the same deserializer for a reference, so a generator that lets a reference
be written as the id
+ * structure puts it under the referring property's {@code id} as well.
Nothing is authorized or
+ * redacted here: the id property is checked when it is assigned. A redacted
member leaves the id
+ * bean incomplete, so the ids it should have told apart may collide and
Jackson reports the
+ * conflict; an id bean that does not construct at all is dropped by {@link
RedactionAwareDeserializer}
+ * and Jackson then fails to bind the {@code null} id. Both fail the read
rather than bind a wrong id.
+ */
+final class ObjectIdPathDeserializer extends DelegatingDeserializer {
+
+ private final String memberName;
+
+ ObjectIdPathDeserializer(JsonDeserializer<?> delegate, String memberName) {
+ super(delegate);
+ this.memberName = memberName;
+ }
+
+ @Override
+ protected JsonDeserializer<?> newDelegatingInstance(JsonDeserializer<?>
newDelegatee) {
+ return new ObjectIdPathDeserializer(newDelegatee, memberName);
+ }
+
+ @Override
+ public Object deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
+ if (!ParameterAuthorizationContext.isActive()) {
+ return super.deserialize(p, ctxt);
+ }
+
ParameterAuthorizationContext.pushPath(ParameterAuthorizationContext.pathFor(memberName));
+ try {
+ return super.deserialize(p, ctxt);
+ } finally {
+ ParameterAuthorizationContext.popPath();
+ }
+ }
+
+ @Override
+ public Object deserializeWithType(JsonParser p, DeserializationContext
ctxt, TypeDeserializer typeDeserializer)
+ throws IOException {
+ if (!ParameterAuthorizationContext.isActive()) {
+ return super.deserializeWithType(p, ctxt, typeDeserializer);
+ }
+
ParameterAuthorizationContext.pushPath(ParameterAuthorizationContext.pathFor(memberName));
+ try {
+ return super.deserializeWithType(p, ctxt, typeDeserializer);
+ } finally {
+ ParameterAuthorizationContext.popPath();
+ }
+ }
+}
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 a308a039e..89ac2f2f2 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
@@ -103,18 +103,32 @@ 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.
+ * property rather than through the builder's. Rebuild the reader around a
wrapped one, and around
+ * a deserializer that puts a bean-typed id's members under the id
property's path.
*/
private static void authorizeObjectIdProperty(BeanDeserializerBuilder
builder) {
ObjectIdReader reader = builder.getObjectIdReader();
- if (reader == null || reader.idProperty == null
- || reader.idProperty instanceof
AuthorizingSettableBeanProperty) {
- return;
+ if (reader != null) {
+ builder.setObjectIdReader(authorizedObjectIdReader(reader));
}
- 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 same rebuild for a reader Jackson constructs later, in {@code
createContextual}, for a
+ * {@code @JsonIdentityInfo} placed on the referring property; returns the
reader itself when it
+ * carries no id property or is already rebuilt.
+ */
+ static ObjectIdReader authorizedObjectIdReader(ObjectIdReader reader) {
+ if (reader.idProperty == null || reader.getDeserializer() instanceof
ObjectIdPathDeserializer) {
+ return reader;
+ }
+ String memberName = memberNameOf(reader.idProperty);
+ SettableBeanProperty idProperty = reader.idProperty instanceof
AuthorizingSettableBeanProperty
+ ? reader.idProperty
+ : new AuthorizingSettableBeanProperty(reader.idProperty,
memberName);
+ JsonDeserializer<?> idDeserializer = new
ObjectIdPathDeserializer(reader.getDeserializer(), memberName);
+ return ObjectIdReader.construct(reader.getIdType(),
reader.propertyName,
+ reader.generator, idDeserializer, idProperty, reader.resolver);
}
/**
diff --git
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/RedactionAwareDeserializer.java
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/RedactionAwareDeserializer.java
index 22e6dd501..c70ecfd6f 100644
---
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/RedactionAwareDeserializer.java
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/RedactionAwareDeserializer.java
@@ -19,9 +19,12 @@
package org.apache.struts2.rest.handler.jackson;
import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.deser.BeanDeserializerBase;
+import com.fasterxml.jackson.databind.deser.impl.ObjectIdReader;
import com.fasterxml.jackson.databind.deser.std.DelegatingDeserializer;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -62,6 +65,29 @@ final class RedactionAwareDeserializer extends
DelegatingDeserializer {
return new RedactionAwareDeserializer(newDelegatee);
}
+ /**
+ * A {@code @JsonIdentityInfo} on the referring property makes Jackson
build a fresh
+ * {@code ObjectIdReader} here, after {@link
ParameterAuthorizingModule#updateBuilder} rebuilt the
+ * class-level one; give it the same treatment. A bean serialized as an
array keeps the properties
+ * it reads in an array of its own that {@code withObjectIdReader} does
not rebuild and this wrapper
+ * cannot reach, so a bean-typed id declared on the referring property of
such a bean stays on the
+ * enclosing path.
+ */
+ @Override
+ public JsonDeserializer<?> createContextual(DeserializationContext ctxt,
BeanProperty property)
+ throws JsonMappingException {
+ JsonDeserializer<?> contextual = super.createContextual(ctxt,
property);
+ JsonDeserializer<?> bean = ((DelegatingDeserializer)
contextual).getDelegatee();
+ if (bean instanceof BeanDeserializerBase beanDeserializer &&
beanDeserializer.getObjectIdReader() != null) {
+ ObjectIdReader reader = beanDeserializer.getObjectIdReader();
+ ObjectIdReader authorized =
ParameterAuthorizingModule.authorizedObjectIdReader(reader);
+ if (authorized != reader) {
+ return new
RedactionAwareDeserializer(beanDeserializer.withObjectIdReader(authorized));
+ }
+ }
+ return contextual;
+ }
+
@Override
public Object deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
if (!ParameterAuthorizationContext.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 9c32a7f6e..7edc42b20 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
@@ -660,6 +660,35 @@ public class ParameterAuthorizingModuleTest extends
TestCase {
assertNull(result.name);
}
+ public void testBeanTypedObjectIdMembersAuthorizedUnderTheIdPath() throws
Exception {
+ // The id value is read through the ObjectIdReader's own deserializer;
a bean-typed id's
+ // members must be checked under id, not against the enclosing bean's
same-named members.
+ Set<String> granted = Set.of("id", "k", "name");
+ bind((path, t, a) -> granted.contains(path), new KeyIdentified());
+ KeyIdentified result =
mapper.readValue("{\"id\":{\"k\":\"x\"},\"name\":\"alice\"}",
KeyIdentified.class);
+ assertEquals("alice", result.name);
+ assertNotNull(result.id);
+ assertNull("id member authorized by the enclosing bean's grant for [k]
?", result.id.k);
+ }
+
+ public void testBeanTypedObjectIdMembersBoundWhenGrantedUnderTheIdPath()
throws Exception {
+ Set<String> granted = Set.of("id", "id.k");
+ bind((path, t, a) -> granted.contains(path), new KeyIdentified());
+ KeyIdentified result =
mapper.readValue("{\"id\":{\"k\":\"x\"},\"name\":\"alice\"}",
KeyIdentified.class);
+ assertEquals("x", result.id.k);
+ assertNull(result.name);
+ }
+
+ public void
testBeanTypedObjectIdDeclaredOnTheReferencingPropertyAuthorizedUnderTheIdPath()
throws Exception {
+ // A per-property @JsonIdentityInfo builds its reader in
createContextual, after the module ran.
+ Set<String> granted = Set.of("child", "child.id", "child.k",
"child.name");
+ bind((path, t, a) -> granted.contains(path), new
KeyIdentifiedHolder());
+ KeyIdentifiedHolder result =
mapper.readValue("{\"child\":{\"id\":{\"k\":\"x\"},\"name\":\"alice\"}}",
+ KeyIdentifiedHolder.class);
+ assertEquals("alice", result.child.name);
+ assertNull("id member authorized by the referring bean's grant for
[child.k] ?", result.child.id.k);
+ }
+
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.
@@ -1075,6 +1104,38 @@ public class ParameterAuthorizingModuleTest extends
TestCase {
public record IdentifiedRecord(int id, String name) {
}
+ public static class Key {
+ public String k;
+
+ @Override
+ public boolean equals(Object other) {
+ return other instanceof Key that && java.util.Objects.equals(k,
that.k);
+ }
+
+ @Override
+ public int hashCode() {
+ return java.util.Objects.hashCode(k);
+ }
+ }
+
+ @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class,
property = "id")
+ public static class KeyIdentified {
+ public Key id;
+ public String k;
+ public String name;
+ }
+
+ public static class PlainKeyed {
+ public Key id;
+ public String k;
+ public String name;
+ }
+
+ public static class KeyIdentifiedHolder {
+ @JsonIdentityInfo(generator =
ObjectIdGenerators.PropertyGenerator.class, property = "id")
+ public PlainKeyed child;
+ }
+
public record PlainRecord(int id, String name) {
}