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 0be1f3f30 WW-5747 fix(rest): leave the parser at the end of an object
the redaction wrapper drops (#1946)
0be1f3f30 is described below
commit 0be1f3f30e1cb8ae62d67ecff6fd5227c65c957c
Author: Lukasz Lenart <[email protected]>
AuthorDate: Tue Sep 15 16:14:47 2026 +0200
WW-5747 fix(rest): leave the parser at the end of an object the redaction
wrapper drops (#1946)
RedactionAwareDeserializer drops a bean whose construction fails after
a @StrutsParameter redaction by catching the JsonMappingException and
returning null. It returned without moving the parser, so whatever
tokens of the dropped object were still unread went to the enclosing
bean: a creator-bound bean failing on its last creator parameter with
fields after it, or a bean whose bean-typed @JsonIdentityInfo id failed
to construct and could not be bound, left its remaining fields to the
parent - a same-named parent property took the child's value, the
parent's own later properties were lost, and an unknown field failed
the parent inside a scope already marked redacted, dropping the root.
The wrapper now records the context of the object or array it is
entered on and, before returning null, skips to that value's end token,
consuming nested structures whole. The end is recognised by context
identity - the first end token whose context no longer descends from
the recorded one - which every parser keeps, including the token
buffers Jackson replays unwrapped and any-setter values from; those
report no nesting depth at all, so a depth comparison drained them.
A parser left without a current token, as Jackson does before splicing
a late type id, is advanced first. A scalar entry needs nothing.
A polymorphic value whose type id is not the first key is read from
Jackson's JsonParserSequence, spliced from a buffer of the keys before
the id and the real parser, and that value alone straddles the splice.
The buffer holds no start token, so that value is the one entered
mid-object on a buffer context while the parser is a splice - a bean
wholly inside the buffer is entered on its start token - and its drop
is not attempted. The exception propagates with the enclosing scope marked,
so
the nearest enclosing bean that can leave its parser in order drops
itself, and through the handlers' root the read fails; either replaces
a silent desynchronisation. A bean nested inside such a value lies
wholly on one side of the splice and is dropped normally.
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../jackson/RedactionAwareDeserializer.java | 70 ++++++++
.../jackson/ParameterAuthorizingModuleTest.java | 194 +++++++++++++++++++++
2 files changed, 264 insertions(+)
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 c70ecfd6f..dd1835523 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,6 +19,9 @@
package org.apache.struts2.rest.handler.jackson;
import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonStreamContext;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.core.util.JsonParserSequence;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
@@ -26,6 +29,7 @@ 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 com.fasterxml.jackson.databind.util.TokenBufferReadContext;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
@@ -88,12 +92,68 @@ final class RedactionAwareDeserializer extends
DelegatingDeserializer {
return contextual;
}
+ /**
+ * The context of the object or array about to be read, when the parser
stands on its start token
+ * or already inside it; {@code null} for a scalar, which the failed read
consumes whole.
+ */
+ private static JsonStreamContext structuredValue(JsonParser p) {
+ JsonToken token = p.currentToken();
+ if (token == null || !(token.isStructStart() || token ==
JsonToken.FIELD_NAME)) {
+ return null;
+ }
+ return p.getParsingContext();
+ }
+
+ /**
+ * Dropping the object must leave the parser on its end token, or the
fields left unread land in
+ * the enclosing bean. The end is found by context identity, which every
parser keeps, including
+ * the token buffers Jackson replays unwrapped and any-setter values from.
The one value that
+ * cannot be followed is a polymorphic one Jackson reads from a parser
spliced from a buffer and
+ * the real parser — a type id that is not the first key, or a visible one
— because that value
+ * straddles the splice. The buffer holds no start token, so such a value
is entered mid-object on
+ * a buffer context while the parser is a splice, and that is what is
refused. The test also
+ * catches a type-id-first bean inside an outer value's buffer, which
could have been followed;
+ * its drop escalates to the enclosing bean instead, which errs on the
side of dropping more.
+ */
+ private static boolean canResync(JsonParser p, JsonToken entry,
JsonStreamContext value) {
+ return value == null || !(p instanceof JsonParserSequence)
+ || !(value instanceof TokenBufferReadContext) || entry !=
JsonToken.FIELD_NAME;
+ }
+
+ private static void skipToEndOf(JsonParser p, JsonStreamContext value)
throws IOException {
+ if (value == null) {
+ return;
+ }
+ JsonToken token = p.hasCurrentToken() ? p.currentToken() :
p.nextToken();
+ while (token != null) {
+ if (token.isStructStart()) {
+ p.skipChildren();
+ token = p.currentToken();
+ }
+ if (token.isStructEnd() && !within(p.getParsingContext(), value)) {
+ return;
+ }
+ token = p.nextToken();
+ }
+ }
+
+ private static boolean within(JsonStreamContext context, JsonStreamContext
value) {
+ for (JsonStreamContext current = context; current != null; current =
current.getParent()) {
+ if (current == value) {
+ return true;
+ }
+ }
+ return false;
+ }
+
@Override
public Object deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
if (!ParameterAuthorizationContext.isActive()) {
return super.deserialize(p, ctxt);
}
ParameterAuthorizationContext.pushRedactionScope();
+ JsonToken entry = p.currentToken();
+ JsonStreamContext value = structuredValue(p);
boolean swallowed = false;
try {
try {
@@ -102,6 +162,15 @@ final class RedactionAwareDeserializer extends
DelegatingDeserializer {
if
(!ParameterAuthorizationContext.wasRedactedInCurrentScope()) {
throw e;
}
+ if (!canResync(p, entry, value)) {
+ // The nearest enclosing object that can leave its parser
in order drops itself.
+ LOG.warn("REST body object of type [{}] failed to
construct after @StrutsParameter " +
+ "redaction dropped one of its properties
and cannot be dropped in place; " +
+ "leaving it to the enclosing object: {}",
+ handledType() != null ? handledType().getName() :
"?", e.getMessage());
+ swallowed = true;
+ throw e;
+ }
// If this object had a property redacted AND also hit an
unrelated mapping error,
// the two are indistinguishable here, so the unrelated error
is folded into
// "object dropped". This is deliberately fail-closed: we
never expose a
@@ -109,6 +178,7 @@ final class RedactionAwareDeserializer extends
DelegatingDeserializer {
LOG.warn("REST body object of type [{}] failed to construct
after @StrutsParameter " +
"redaction dropped one of its properties;
treating the object as unauthorized: {}",
handledType() != null ? handledType().getName() : "?",
e.getMessage());
+ skipToEndOf(p, value);
swallowed = true;
return null;
}
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 7edc42b20..c0837a8e4 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
@@ -34,6 +34,7 @@ import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
@@ -689,6 +690,116 @@ public class ParameterAuthorizingModuleTest extends
TestCase {
assertNull("id member authorized by the referring bean's grant for
[child.k] ?", result.child.id.k);
}
+ public void testDroppedObjectLeavesTheParserAtItsEnd_nullObjectId() throws
Exception {
+ // The id bean fails on a redacted member, so the null id fails
Jackson's binding mid-object;
+ // the dropped object's remaining fields must not be read by the
parent.
+ Set<String> granted = Set.of("child", "child.id", "child.name",
"other", "name");
+ bind((path, t, a) -> granted.contains(path), new StrictHolder());
+ StrictHolder result = mapper.readValue(
+
"{\"child\":{\"id\":{\"k\":\"a\"},\"name\":\"x\"},\"other\":\"o\",\"name\":\"h\"}",
+ StrictHolder.class);
+ assertNull(result.child);
+ assertEquals("o", result.other);
+ assertEquals("child's name read into the parent ?", "h", result.name);
+ }
+
+ public void testDroppedObjectLeavesTheParserAtItsEnd_creatorFailure()
throws Exception {
+ // A creator with one parameter constructs as soon as it arrives; a
redacted primitive fails it
+ // with the rest of the object, including a nested one, still unread.
+ mapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES,
true);
+ Set<String> granted = Set.of("money", "money.note", "money.name",
"other", "name");
+ bind((path, t, a) -> granted.contains(path), new StrictHolder());
+ StrictHolder result = mapper.readValue(
+
"{\"money\":{\"amount\":5,\"detail\":{\"x\":1},\"name\":\"x\"},\"other\":\"o\",\"name\":\"h\"}",
+ StrictHolder.class);
+ assertNull(result.money);
+ assertEquals("o", result.other);
+ assertEquals("h", result.name);
+ }
+
+ public void testDroppedPolymorphicObjectWithLateTypeIdFailsTheRead()
throws Exception {
+ // With the type id after other keys Jackson reads the subtype from a
parser spliced from a
+ // buffer and the real parser; the value straddles the splice, so its
drop cannot resync and
+ // is not swallowed. Read into an existing bean as the handlers do, so
nothing above it can
+ // drop itself instead.
+ mapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES,
true);
+ Set<String> granted = Set.of("pet", "pet.name");
+ bind((path, t, a) -> granted.contains(path), new StrictHolder());
+ try {
+ mapper.readerForUpdating(new StrictHolder())
+
.readValue("{\"pet\":{\"legs\":4,\"@type\":\"strict\",\"name\":\"x\"},\"name\":\"h\"}");
+ fail("a drop that cannot resync the parser must fail the read");
+ } catch (JsonMappingException expected) {
+ assertTrue(expected.getMessage(),
expected.getMessage().contains("legs"));
+ }
+ }
+
+ public void testDroppedObjectNestedInALateTypeIdValueStillResyncs() throws
Exception {
+ // A bean nested inside the spliced value lies wholly on one side of
the splice.
+ Set<String> granted = Set.of("pet", "pet.legs", "pet.name",
"pet.owner", "name");
+ bind((path, t, a) -> granted.contains(path), new StrictHolder());
+ StrictHolder result = mapper.readValue(
+
"{\"pet\":{\"legs\":4,\"@type\":\"strict\",\"owner\":{\"k\":\"a\"},\"name\":\"x\"},\"name\":\"h\"}",
+ StrictHolder.class);
+ StrictPet pet = (StrictPet) result.pet;
+ assertNull(pet.owner);
+ assertEquals("x", pet.name);
+ assertEquals("h", result.name);
+ }
+
+ public void
testDroppedLateTypeIdValueNestedInAnotherLateTypeIdValueDoesNotLeak() throws
Exception {
+ // The inner splice is based on the outer value's buffer, so its root
context has a buffer
+ // parent; it still straddles its own splice and must be refused,
escalating the drop.
+ mapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES,
true);
+ Set<String> granted = Set.of("mid", "mid.pet", "mid.pet.name",
"mid.pet.owner", "mid.name", "name");
+ bind((path, t, a) -> granted.contains(path), new PolyOuter());
+ PolyOuter result = mapper.readValue(
+
"{\"mid\":{\"pet\":{\"legs\":4,\"@type\":\"strict\",\"owner\":{\"k\":\"a\"},\"name\":\"pet\"},"
+ +
"\"name\":\"midname\",\"@type\":\"mid\"},\"name\":\"outer\"}",
+ PolyOuter.class);
+ assertNull("a drop that cannot resync must escalate, not leak the
pet's fields into mid", result);
+ }
+
+ public void testRefusedResyncDropsTheNearestEnclosingObject() throws
Exception {
+ // The parent had no redaction of its own; the refused drop below it
must still mark it.
+ mapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES,
true);
+ Set<String> granted = Set.of("mid", "mid.secret", "mid.pet",
"mid.pet.name", "name");
+ bind((path, t, a) -> granted.contains(path), new StrictOuter());
+ StrictOuter result = mapper.readValue(
+
"{\"mid\":{\"secret\":\"s\",\"pet\":{\"legs\":4,\"@type\":\"strict\",\"name\":\"pet\"}},\"name\":\"outer\"}",
+ StrictOuter.class);
+ assertNull(result.mid);
+ assertEquals("outer", result.name);
+ }
+
+ public void
testDroppedObjectUnderADroppedPolymorphicChildLeavesTheParserAtItsEnd() throws
Exception {
+ // Jackson clears the parser's current token before splicing for a
late type id; when the
+ // subtype's drop is refused and the parent's own wrapper swallows, it
must still resync.
+ mapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES,
true);
+ Set<String> granted = Set.of("mid", "mid.pet", "mid.pet.name", "name");
+ bind((path, t, a) -> granted.contains(path), new StrictOuter());
+ StrictOuter result = mapper.readValue(
+
"{\"mid\":{\"secret\":\"s\",\"pet\":{\"legs\":4,\"@type\":\"strict\",\"name\":\"pet\"}},\"name\":\"outer\"}",
+ StrictOuter.class);
+ assertNull(result.mid);
+ assertEquals("dropped pet's name read into the outer bean ?", "outer",
result.name);
+ }
+
+ public void testDroppedObjectInsideAnUnwrappedReplayStopsAtItsEnd() throws
Exception {
+ // Unwrapped properties are replayed from a token buffer whose
contexts carry no nesting
+ // depth; a drop there must stop at the object's end, not drain the
buffer.
+ Set<String> granted = Set.of("inner", "inner.keys", "inner.name",
"other");
+ bind((path, t, a) -> granted.contains(path), new UnwrappedHolder());
+ UnwrappedHolder result = mapper.readValue(
+
"{\"keys\":[{\"k\":\"a\"},{\"k\":\"b\"}],\"name\":\"n\",\"other\":\"o\"}",
+ UnwrappedHolder.class);
+ assertNotNull("unwrapped bean dropped because the replay was drained
?", result.inner);
+ assertEquals(2, result.inner.keys.size());
+ assertNull(result.inner.keys.get(0));
+ assertEquals("n", result.inner.name);
+ assertEquals("o", result.other);
+ }
+
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.
@@ -1104,6 +1215,89 @@ public class ParameterAuthorizingModuleTest extends
TestCase {
public record IdentifiedRecord(int id, String name) {
}
+ public record StrictKey(String k) {
+ public StrictKey {
+ java.util.Objects.requireNonNull(k);
+ }
+ }
+
+ @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class,
property = "id")
+ public static class StrictIdentified {
+ public StrictKey id;
+ public String name;
+ }
+
+ public static class StrictMoney {
+ public final int amount;
+ public Address detail;
+ public String name;
+
+ @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+ public StrictMoney(@JsonProperty("amount") int amount) {
+ this.amount = amount;
+ }
+ }
+
+ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "@type")
+ @JsonSubTypes(@JsonSubTypes.Type(value = StrictPet.class, name = "strict"))
+ public abstract static class StrictAnimal {
+ }
+
+ public static class StrictPet extends StrictAnimal {
+ public final int legs;
+ public StrictKey owner;
+ public String name;
+
+ @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
+ public StrictPet(@JsonProperty("legs") int legs) {
+ this.legs = legs;
+ }
+ }
+
+ public static class StrictMid {
+ public String secret;
+ public StrictAnimal pet;
+ }
+
+ public static class StrictOuter {
+ public StrictMid mid;
+ public String name;
+ }
+
+ public static class UnwrappedInner {
+ public List<StrictKey> keys;
+ public String name;
+ }
+
+ public static class UnwrappedHolder {
+ @JsonUnwrapped
+ public UnwrappedInner inner;
+ public String other;
+ }
+
+ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "@type")
+ @JsonSubTypes(@JsonSubTypes.Type(value = StrictMidPoly.class, name =
"mid"))
+ public abstract static class StrictContainer {
+ }
+
+ public static class StrictMidPoly extends StrictContainer {
+ public StrictAnimal pet;
+ public String name;
+ }
+
+ public static class PolyOuter {
+ public StrictContainer mid;
+ public String name;
+ }
+
+ public static class StrictHolder {
+ public StrictIdentified child;
+ public StrictMoney money;
+ public StrictAnimal pet;
+ public String other;
+ public String name;
+ }
+
public static class Key {
public String k;