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 e5eb01abd WW-5642 fix(rest): authorize @StrutsParameter on
record/creator-bound REST body properties (#1774)
e5eb01abd is described below
commit e5eb01abdab0aefdaf5c2ce53704bd6b21203f39
Author: ⳕⲛτⲉⲅⲥⲉⳏτⲟⲅ 🕵🏻 <[email protected]>
AuthorDate: Fri Jul 17 15:21:47 2026 +0530
WW-5642 fix(rest): authorize @StrutsParameter on record/creator-bound REST
body properties (#1774)
* fix(rest): authorize @StrutsParameter on record/creator-bound REST body
properties
ParameterAuthorizingModule enforces @StrutsParameter on REST/JSON body
deserialization by wrapping each property's deserializeAndSet/
deserializeSetAndReturn. Jackson never calls either method for
creator-bound properties (Java records, @JsonCreator constructors,
@ConstructorProperties) — it calls SettableBeanProperty#deserialize
directly, which is declared final and bypasses the wrapper entirely.
With struts.parameters.requireAnnotations enabled, any record-typed
field anywhere in a REST action's request body was populated with no
authorization check at all.
Add AuthorizingValueDeserializer, which wraps the property's value
deserializer instead of the property itself, and install it from
AuthorizingSettableBeanProperty#withValueDeserializer — scoped to
CreatorProperty so ordinary setter/field/builder properties, already
authorized via the existing wrapper, aren't checked twice.
* fix(rest): treat redaction-induced construction failures as unauthorized,
not fatal
AuthorizingValueDeserializer substitutes null for a rejected creator-bound
property (record component, @JsonCreator/@ConstructorProperties param).
For reference-typed, unvalidated components this is a harmless stand-in
for "not set" -- but two cases turn that substitution into an unhandled
exception that crashes deserialization of the entire request body instead
of just dropping the unauthorized subtree:
- A record/constructor with its own non-null validation (e.g. a compact
constructor doing Objects.requireNonNull) throws
ValueInstantiationException when the redacted component reaches it.
- With DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES enabled, Jackson
itself throws MismatchedInputException when a primitive-typed creator
component is redacted to null.
Add RedactionAwareDeserializer, wrapping every bean-type deserializer via
a new BeanDeserializerModifier#modifyDeserializer hook. It tracks (via a
new redaction-scope stack in ParameterAuthorizationContext) whether the
object currently under construction had a property redacted by
authorization; if construction then throws, the object is treated as
unauthorized (returns null) instead of propagating the raw exception --
matching the same fail-closed outcome already used when a non-creator
nested property is rejected outright. A guard test confirms genuine,
unrelated validation failures (nothing redacted) still propagate
normally, so real client errors aren't masked.
Also verified (and added regression coverage for) the other gaps raised
in review: static factory-method @JsonCreator, @ConstructorProperties,
top-level records, 3-level nested creator chains, and List/Map creator
params whose elements are further creator-bound or plain-POJO types --
all of these were already handled correctly by the existing
withValueDeserializer interception.
* test(rest): cover array creator param; document redaction edge cases
Addresses the three non-blocking review notes on WW-5642:
- Add testArrayOfRecordsAsCreatorParam_elementsAuthorizedByIndexedPath
and a WithArray fixture, exercising the type.isArray() branch of
AuthorizingValueDeserializer#prefixForNested so the collection matrix
(List/Map/array) is fully covered.
- Document in AuthorizingValueDeserializer that redacting a primitive
creator component becomes the type default (0/false) when
FAIL_ON_NULL_FOR_PRIMITIVES is off -- a deliberate choice, the client
value never lands either way.
- Document in RedactionAwareDeserializer that a redaction co-located with
an unrelated mapping error is folded into "object dropped" -- a
deliberate fail-closed trade-off, never exposing a partial object.
---------
Co-authored-by: g0w6y <[email protected]>
---
.../parameter/ParameterAuthorizationContext.java | 48 +++++
.../ParameterAuthorizationContextTest.java | 43 ++++
.../jackson/AuthorizingSettableBeanProperty.java | 20 ++
.../jackson/AuthorizingValueDeserializer.java | 92 ++++++++
.../jackson/ParameterAuthorizingModule.java | 11 +
.../jackson/RedactionAwareDeserializer.java | 96 +++++++++
.../jackson/ParameterAuthorizingModuleTest.java | 240 +++++++++++++++++++++
7 files changed, 550 insertions(+)
diff --git
a/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContext.java
b/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContext.java
index bcd35ce32..5f8e6932a 100644
---
a/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContext.java
+++
b/core/src/main/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContext.java
@@ -39,6 +39,7 @@ public final class ParameterAuthorizationContext {
private static final ThreadLocal<State> STATE = new ThreadLocal<>();
private static final ThreadLocal<Deque<String>> PATH_STACK =
ThreadLocal.withInitial(ArrayDeque::new);
+ private static final ThreadLocal<Deque<RedactionScope>> REDACTION_STACK =
ThreadLocal.withInitial(ArrayDeque::new);
private ParameterAuthorizationContext() {
// utility
@@ -67,6 +68,7 @@ public final class ParameterAuthorizationContext {
public static void unbind() {
STATE.remove();
PATH_STACK.remove();
+ REDACTION_STACK.remove();
}
/**
@@ -130,6 +132,48 @@ public final class ParameterAuthorizationContext {
return prefix.isEmpty() ? propertyName : prefix + "." + propertyName;
}
+ /**
+ * Pushes a fresh redaction scope, tracking whether any creator-bound
property directly inside
+ * the object about to be constructed was rejected by {@link
#isAuthorized}. Callers that
+ * construct a bean via a creator/builder (e.g. a bean-level deserializer
wrapper) push a scope
+ * before construction and pop it in a {@code finally} block afterwards.
+ */
+ public static void pushRedactionScope() {
+ REDACTION_STACK.get().push(new RedactionScope());
+ }
+
+ /**
+ * Pops the current redaction scope. Has no effect if the stack is empty.
+ */
+ public static void popRedactionScope() {
+ Deque<RedactionScope> stack = REDACTION_STACK.get();
+ if (!stack.isEmpty()) {
+ stack.pop();
+ }
+ }
+
+ /**
+ * Marks the current redaction scope as having dropped at least one
property. Used to
+ * distinguish "this construction failed because we substituted a redacted
value" (safe to
+ * treat the whole object as unauthorized) from "this construction failed
for an unrelated
+ * reason" (a genuine client error, which must still propagate).
+ */
+ public static void markRedacted() {
+ RedactionScope top = REDACTION_STACK.get().peek();
+ if (top != null) {
+ top.redacted = true;
+ }
+ }
+
+ /**
+ * @return {@code true} if {@link #markRedacted()} was called since the
current redaction scope
+ * was pushed.
+ */
+ public static boolean wasRedactedInCurrentScope() {
+ RedactionScope top = REDACTION_STACK.get().peek();
+ return top != null && top.redacted;
+ }
+
private static final class State {
final ParameterAuthorizer authorizer;
final Object target;
@@ -141,4 +185,8 @@ public final class ParameterAuthorizationContext {
this.action = action;
}
}
+
+ private static final class RedactionScope {
+ boolean redacted;
+ }
}
diff --git
a/core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContextTest.java
b/core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContextTest.java
index 76e4a3466..a8a2264c7 100644
---
a/core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContextTest.java
+++
b/core/src/test/java/org/apache/struts2/interceptor/parameter/ParameterAuthorizationContextTest.java
@@ -126,4 +126,47 @@ public class ParameterAuthorizationContextTest {
assertThat(ParameterAuthorizationContext.isActive()).isFalse();
assertThat(ParameterAuthorizationContext.currentPathPrefix()).isEmpty();
}
+
+ @Test
+ public void redactionScope_notRedactedByDefault() {
+ ParameterAuthorizationContext.pushRedactionScope();
+
assertThat(ParameterAuthorizationContext.wasRedactedInCurrentScope()).isFalse();
+ }
+
+ @Test
+ public void markRedacted_marksCurrentScopeOnly() {
+ ParameterAuthorizationContext.markRedacted(); // no scope pushed yet
-- must be a safe no-op
+ ParameterAuthorizationContext.pushRedactionScope();
+
assertThat(ParameterAuthorizationContext.wasRedactedInCurrentScope()).isFalse();
+ ParameterAuthorizationContext.markRedacted();
+
assertThat(ParameterAuthorizationContext.wasRedactedInCurrentScope()).isTrue();
+ }
+
+ @Test
+ public void redactionScope_nestedScopesAreIndependent() {
+ ParameterAuthorizationContext.pushRedactionScope(); // outer
+ ParameterAuthorizationContext.pushRedactionScope(); // inner
+ ParameterAuthorizationContext.markRedacted(); // marks inner only
+
assertThat(ParameterAuthorizationContext.wasRedactedInCurrentScope()).isTrue();
+ ParameterAuthorizationContext.popRedactionScope(); // back to outer
+ assertThat(ParameterAuthorizationContext.wasRedactedInCurrentScope())
+ .as("marking the inner scope must not leak into the outer
scope")
+ .isFalse();
+ }
+
+ @Test
+ public void popRedactionScope_onEmptyStack_isSafeNoOp() {
+ ParameterAuthorizationContext.popRedactionScope();
+
assertThat(ParameterAuthorizationContext.wasRedactedInCurrentScope()).isFalse();
+ }
+
+ @Test
+ public void unbind_clearsRedactionStack() {
+ ParameterAuthorizationContext.pushRedactionScope();
+ ParameterAuthorizationContext.markRedacted();
+ ParameterAuthorizationContext.unbind();
+ // A fresh scope after unbind must not inherit the pre-unbind
redaction state.
+ ParameterAuthorizationContext.pushRedactionScope();
+
assertThat(ParameterAuthorizationContext.wasRedactedInCurrentScope()).isFalse();
+ }
}
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 d6c3a812a..3da5c5f09 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,8 @@ package org.apache.struts2.rest.handler.jackson;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JavaType;
+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;
@@ -58,6 +60,22 @@ public class AuthorizingSettableBeanProperty extends
SettableBeanProperty.Delega
return new AuthorizingSettableBeanProperty(d);
}
+ /**
+ * Creator-bound properties (records, {@code @JsonCreator} constructors)
never reach
+ * {@link #deserializeAndSet}/{@link #deserializeSetAndReturn}: Jackson
calls the {@code final}
+ * {@code SettableBeanProperty#deserialize} directly, through this
property's own value deserializer.
+ * Wrap that deserializer with {@link AuthorizingValueDeserializer},
scoped to {@link CreatorProperty}
+ * so ordinary setter/field/builder properties -- already authorized below
-- aren't double-checked.
+ */
+ @Override
+ public SettableBeanProperty withValueDeserializer(JsonDeserializer<?>
deser) {
+ JsonDeserializer<?> effective = deser;
+ if (delegate instanceof CreatorProperty && !(deser instanceof
AuthorizingValueDeserializer)) {
+ effective = new AuthorizingValueDeserializer(deser, getName());
+ }
+ return _with(delegate.withValueDeserializer(effective));
+ }
+
@Override
public void deserializeAndSet(JsonParser p, DeserializationContext ctxt,
Object instance) throws IOException {
if (!ParameterAuthorizationContext.isActive()) {
@@ -68,6 +86,7 @@ public class AuthorizingSettableBeanProperty extends
SettableBeanProperty.Delega
if (!ParameterAuthorizationContext.isAuthorized(path)) {
LOG.warn("REST body parameter [{}] rejected by @StrutsParameter
authorization on [{}]",
path, instance.getClass().getName());
+ ParameterAuthorizationContext.markRedacted();
p.skipChildren();
return;
}
@@ -88,6 +107,7 @@ public class AuthorizingSettableBeanProperty extends
SettableBeanProperty.Delega
if (!ParameterAuthorizationContext.isAuthorized(path)) {
LOG.warn("REST body parameter [{}] rejected by @StrutsParameter
authorization on [{}]",
path, instance.getClass().getName());
+ ParameterAuthorizationContext.markRedacted();
p.skipChildren();
return instance;
}
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
new file mode 100644
index 000000000..0a1369e34
--- /dev/null
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/AuthorizingValueDeserializer.java
@@ -0,0 +1,92 @@
+/*
+ * 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 org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
+
+/**
+ * Enforces {@code @StrutsParameter} authorization for creator-bound
properties (Java records,
+ * {@code @JsonCreator} constructors, {@code @ConstructorProperties}), which
Jackson deserializes
+ * through the value deserializer directly rather than through a {@code
SettableBeanProperty}.
+ * See {@link AuthorizingSettableBeanProperty#withValueDeserializer} for where
this is installed.
+ */
+final class AuthorizingValueDeserializer extends DelegatingDeserializer {
+
+ private static final Logger LOG =
LogManager.getLogger(AuthorizingValueDeserializer.class);
+
+ private final String propertyName;
+
+ AuthorizingValueDeserializer(JsonDeserializer<?> delegate, String
propertyName) {
+ super(delegate);
+ this.propertyName = propertyName;
+ }
+
+ @Override
+ protected JsonDeserializer<?> newDelegatingInstance(JsonDeserializer<?>
newDelegatee) {
+ return new AuthorizingValueDeserializer(newDelegatee, propertyName);
+ }
+
+ @Override
+ public Object deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
+ if (!ParameterAuthorizationContext.isActive()) {
+ return super.deserialize(p, ctxt);
+ }
+ String path = ParameterAuthorizationContext.pathFor(propertyName);
+ if (!ParameterAuthorizationContext.isAuthorized(path)) {
+ LOG.warn("REST body parameter [{}] rejected by @StrutsParameter
authorization (creator-bound property)", path);
+ ParameterAuthorizationContext.markRedacted();
+ p.skipChildren();
+ // Returning null redacts the value. For a primitive creator
component this becomes the
+ // type default (0/false) unless FAIL_ON_NULL_FOR_PRIMITIVES is on
(then construction
+ // fails and RedactionAwareDeserializer drops the whole object) --
either way the
+ // client-supplied value never lands, which is the point of the
redaction.
+ return null;
+ }
+ ParameterAuthorizationContext.pushPath(prefixForNested(path));
+ try {
+ return super.deserialize(p, ctxt);
+ } finally {
+ ParameterAuthorizationContext.popPath();
+ }
+ }
+
+ /**
+ * For Collection / Map / Array-valued creator parameters, the path to
push for nested element
+ * members is {@code path + "[0]"} -- matching {@code
ParametersInterceptor} bracket-depth
+ * semantics, and {@link AuthorizingSettableBeanProperty#prefixForNested}.
Scalar / bean-valued
+ * parameters push the path unchanged.
+ */
+ private String prefixForNested(String pathOfThisProperty) {
+ Class<?> type = handledType();
+ if (type != null && (Collection.class.isAssignableFrom(type) ||
Map.class.isAssignableFrom(type) || type.isArray())) {
+ return pathOfThisProperty + "[0]";
+ }
+ return pathOfThisProperty;
+ }
+}
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 c90016415..c3a191ef0 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
@@ -20,6 +20,7 @@ package org.apache.struts2.rest.handler.jackson;
import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.DeserializationConfig;
+import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.deser.BeanDeserializerBuilder;
import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;
import com.fasterxml.jackson.databind.deser.SettableBeanProperty;
@@ -59,6 +60,16 @@ public class ParameterAuthorizingModule extends SimpleModule
{
}
return builder;
}
+
+ @Override
+ public JsonDeserializer<?>
modifyDeserializer(DeserializationConfig config,
+ BeanDescription
beanDesc,
+ JsonDeserializer<?>
deserializer) {
+ if (deserializer instanceof RedactionAwareDeserializer) {
+ return deserializer; // idempotent; protect against
double-registration
+ }
+ return new RedactionAwareDeserializer(deserializer);
+ }
});
}
}
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
new file mode 100644
index 000000000..22e6dd501
--- /dev/null
+++
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/RedactionAwareDeserializer.java
@@ -0,0 +1,96 @@
+/*
+ * 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.JsonMappingException;
+import com.fasterxml.jackson.databind.deser.std.DelegatingDeserializer;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
+
+import java.io.IOException;
+
+/**
+ * Wraps every bean-type deserializer so that a construction failure caused by
+ * {@link AuthorizingValueDeserializer} / {@link
AuthorizingSettableBeanProperty} substituting a
+ * redacted ({@code null}) value for an unauthorized property -- e.g. a
record's compact constructor
+ * rejecting a {@code null} it requires, a primitive creator parameter that
can't hold {@code null}
+ * under {@code DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES}, or a
builder's {@code build()}
+ * validating a redacted field -- is treated the same way a rejected
non-creator nested property
+ * already is: the whole object is dropped ({@code null}), rather than the raw
+ * {@link JsonMappingException} crashing the entire request body's
deserialization.
+ *
+ * <p>Installed via {@link ParameterAuthorizingModule#modifyDeserializer},
scoped to every bean
+ * deserializer (per-object construction), regardless of whether that
particular bean turns out to
+ * be creator-bound, builder-bound, or plain setter/field-bound -- the
redaction scope this pushes is
+ * a no-op unless something inside actually calls
+ * {@link ParameterAuthorizationContext#markRedacted()}.</p>
+ *
+ * <p>Only {@link JsonMappingException} thrown while <em>this object's
own</em> redaction scope is
+ * marked is swallowed. A construction failure with no redaction recorded in
the current scope is a
+ * genuine client/data error, unrelated to authorization, and is rethrown
unchanged.</p>
+ */
+final class RedactionAwareDeserializer extends DelegatingDeserializer {
+
+ private static final Logger LOG =
LogManager.getLogger(RedactionAwareDeserializer.class);
+
+ RedactionAwareDeserializer(JsonDeserializer<?> delegate) {
+ super(delegate);
+ }
+
+ @Override
+ protected JsonDeserializer<?> newDelegatingInstance(JsonDeserializer<?>
newDelegatee) {
+ return new RedactionAwareDeserializer(newDelegatee);
+ }
+
+ @Override
+ public Object deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
+ if (!ParameterAuthorizationContext.isActive()) {
+ return super.deserialize(p, ctxt);
+ }
+ ParameterAuthorizationContext.pushRedactionScope();
+ boolean swallowed = false;
+ try {
+ try {
+ return super.deserialize(p, ctxt);
+ } catch (JsonMappingException e) {
+ if
(!ParameterAuthorizationContext.wasRedactedInCurrentScope()) {
+ 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
+ // partially-built object, at the cost of a slightly less
specific error.
+ 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());
+ swallowed = true;
+ return null;
+ }
+ } finally {
+ ParameterAuthorizationContext.popRedactionScope();
+ if (swallowed) {
+ ParameterAuthorizationContext.markRedacted();
+ }
+ }
+ }
+}
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 0fe8dd875..34171e252 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
@@ -18,6 +18,9 @@
*/
package org.apache.struts2.rest.handler.jackson;
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
@@ -25,6 +28,10 @@ import junit.framework.TestCase;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
+import java.beans.ConstructorProperties;
+import java.util.List;
+import java.util.Map;
+
public class ParameterAuthorizingModuleTest extends TestCase {
private ObjectMapper mapper;
@@ -130,6 +137,147 @@ public class ParameterAuthorizingModuleTest extends
TestCase {
assertNull(p.role);
}
+ public void testRecordComponentAuthorizedByPath() throws Exception {
+ bind((path, t, a) -> "recordAddress".equals(path) ||
"recordAddress.city".equals(path), new Person());
+ Person result = mapper.readValue(
+
"{\"recordAddress\":{\"city\":\"Warsaw\",\"secret\":\"admin-only\"}}",
Person.class);
+ assertNotNull(result.recordAddress);
+ assertEquals("Warsaw", result.recordAddress.city());
+ assertNull(result.recordAddress.secret());
+ }
+
+ public void testTopLevelRecordAuthorizedByPath() throws Exception {
+ // The REST body target itself is a record, not merely a nested field
-- exercises the same
+ // creator-bound wrapping at the outermost readValue() call.
+ bind((path, t, a) -> "name".equals(path), new TopLevelRecord("", ""));
+ TopLevelRecord result =
mapper.readValue("{\"name\":\"alice\",\"secret\":\"top\"}",
TopLevelRecord.class);
+ assertEquals("alice", result.name());
+ assertNull(result.secret());
+ }
+
+ public void testStaticFactoryCreatorAuthorizedByPath() throws Exception {
+ // @JsonCreator on a static factory method, rather than a constructor
-- a distinct
+ // ValueInstantiator flavor from the constructor/record cases above.
+ bind((path, t, a) -> "name".equals(path), FactoryCreated.of("", ""));
+ FactoryCreated result =
mapper.readValue("{\"name\":\"alice\",\"secret\":\"top\"}",
FactoryCreated.class);
+ assertEquals("alice", result.name);
+ assertNull(result.secret);
+ }
+
+ public void testConstructorPropertiesAuthorizedByPath() throws Exception {
+ // @ConstructorProperties (java.beans), rather than @JsonCreator --
the other JDK-native
+ // "creator style" Jackson recognizes for properties-based
construction.
+ bind((path, t, a) -> "name".equals(path), new ConstructorPropsBean("",
""));
+ ConstructorPropsBean result =
mapper.readValue("{\"name\":\"alice\",\"secret\":\"top\"}",
ConstructorPropsBean.class);
+ assertEquals("alice", result.name);
+ assertNull(result.secret);
+ }
+
+ public void testCreatorPropertyEntirelyRejected_dropsWholeSubtree() throws
Exception {
+ // "inner" itself is never authorized -- the whole nested
creator-bound object must be
+ // dropped, matching how a rejected non-creator nested bean property
behaves (see
+ // testNestedRejectedAtParent), not partially constructed with
defaults.
+ bind((path, t, a) -> "top".equals(path), new Wrapper("", null));
+ Wrapper result = mapper.readValue(
+
"{\"top\":\"T\",\"inner\":{\"mid\":\"M\",\"innerinner\":{\"bottom\":\"B\",\"secret\":\"S\"}}}",
+ Wrapper.class);
+ assertEquals("T", result.top());
+ assertNull(result.inner());
+ }
+
+ public void testThreeLevelNestedCreatorPathAuthorization() throws
Exception {
+ bind((path, t, a) -> "top".equals(path) || "inner".equals(path) ||
"inner.mid".equals(path)
+ || "inner.innerinner".equals(path) ||
"inner.innerinner.bottom".equals(path),
+ new Wrapper("", null));
+ Wrapper result = mapper.readValue(
+
"{\"top\":\"T\",\"inner\":{\"mid\":\"M\",\"innerinner\":{\"bottom\":\"B\",\"secret\":\"S\"}}}",
+ Wrapper.class);
+ assertEquals("T", result.top());
+ assertEquals("M", result.inner().mid());
+ assertEquals("B", result.inner().innerinner().bottom());
+ assertNull(result.inner().innerinner().secret());
+ }
+
+ public void
testListOfRecordsAsCreatorParam_elementsAuthorizedByIndexedPath() throws
Exception {
+ bind((path, t, a) -> "items".equals(path) ||
"items[0].value".equals(path), new WithList("", null));
+ WithList result = mapper.readValue(
+
"{\"label\":\"x\",\"items\":[{\"value\":\"v1\",\"secret\":\"s1\"}]}",
WithList.class);
+ assertNull("unauthorized top-level creator property must be dropped",
result.label());
+ assertEquals(1, result.items().size());
+ assertEquals("v1", result.items().get(0).value());
+ assertNull(result.items().get(0).secret());
+ }
+
+ public void
testMapOfRecordsAsCreatorParam_elementsAuthorizedByIndexedPath() throws
Exception {
+ bind((path, t, a) -> "items".equals(path) ||
"items[0].value".equals(path), new WithMap("", null));
+ WithMap result = mapper.readValue(
+
"{\"label\":\"x\",\"items\":{\"a\":{\"value\":\"v1\",\"secret\":\"s1\"}}}",
WithMap.class);
+ assertNull(result.label());
+ assertEquals("v1", result.items().get("a").value());
+ assertNull(result.items().get("a").secret());
+ }
+
+ public void
testArrayOfRecordsAsCreatorParam_elementsAuthorizedByIndexedPath() throws
Exception {
+ // Array-valued creator param -- exercises the type.isArray() branch
of prefixForNested,
+ // rounding out the collection matrix alongside the List/Map cases
above.
+ bind((path, t, a) -> "items".equals(path) ||
"items[0].value".equals(path), new WithArray("", null));
+ WithArray result = mapper.readValue(
+
"{\"label\":\"x\",\"items\":[{\"value\":\"v1\",\"secret\":\"s1\"}]}",
WithArray.class);
+ assertNull(result.label());
+ assertEquals(1, result.items().length);
+ assertEquals("v1", result.items()[0].value());
+ assertNull(result.items()[0].secret());
+ }
+
+ public void
testListOfPlainPojosAsCreatorParam_elementsAuthorizedByIndexedPath() throws
Exception {
+ // The creator param itself (List<PlainAddr>) is record-bound, but its
elements are an
+ // ordinary field-based POJO -- exercises the two wrapping mechanisms
handing off to each
+ // other across a collection boundary.
+ bind((path, t, a) -> "addrs".equals(path) ||
"addrs[0].city".equals(path), new ListOfPlain("", null));
+ ListOfPlain result = mapper.readValue(
+
"{\"label\":\"x\",\"addrs\":[{\"city\":\"Warsaw\",\"zip\":\"00-001\"}]}",
ListOfPlain.class);
+ assertNull(result.label());
+ assertEquals("Warsaw", result.addrs().get(0).city);
+ assertNull(result.addrs().get(0).zip);
+ }
+
+ public void
testValidatingRecordRejectedRequiredComponent_dropsObjectInsteadOfThrowing()
throws Exception {
+ // Regression for the gap flagged in review: a record whose compact
constructor requires a
+ // non-null component throws ValueInstantiationException when that
component is redacted by
+ // authorization. Without RedactionAwareDeserializer, that exception
used to propagate raw
+ // and fail the whole request body; it must instead be treated as
"this object is
+ // unauthorized" (null), the same fail-closed outcome as
testCreatorPropertyEntirelyRejected.
+ bind((path, t, a) -> "name".equals(path), new Validated("", "x"));
+ Validated result =
mapper.readValue("{\"name\":\"alice\",\"secret\":\"top\"}", Validated.class);
+ assertNull("construction failure caused by our own redaction must drop
the object, not throw",
+ result);
+ }
+
+ public void
testPrimitiveCreatorParamRejected_underFailOnNullForPrimitives_dropsObjectInsteadOfThrowing()
+ throws Exception {
+ // Regression for the other gap flagged in review: with
FAIL_ON_NULL_FOR_PRIMITIVES enabled,
+ // rejecting a primitive-typed creator component makes Jackson itself
throw
+ // MismatchedInputException when building the object. Same fail-closed
contract as above.
+ mapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES,
true);
+ bind((path, t, a) -> "currency".equals(path), new Money(0, ""));
+ Money result =
mapper.readValue("{\"amount\":100,\"currency\":\"USD\"}", Money.class);
+ assertNull(result);
+ }
+
+ public void testValidatingRecord_genuineClientErrorStillPropagates()
throws Exception {
+ // Guards against over-broad swallowing: when nothing in this object
was redacted, a
+ // construction failure is a genuine client/data error and must still
be reported, not
+ // silently converted into null.
+ bind((path, t, a) -> true, new Validated("", "x"));
+ try {
+ mapper.readValue("{\"name\":\"alice\",\"secret\":null}",
Validated.class);
+ fail("expected the compact constructor's own validation failure to
propagate");
+ } catch (Exception expected) {
+ // ValueInstantiationException (or its cause chain) -- exact type
not asserted to avoid
+ // coupling this test to Jackson's internal exception hierarchy.
+ }
+ }
+
// --- Fixtures ---
public static class Person {
@@ -139,6 +287,7 @@ public class ParameterAuthorizingModuleTest extends
TestCase {
public java.util.List<Address> addresses;
public Address[] addressArray;
public java.util.Map<String, Address> addressMap;
+ public RecordAddress recordAddress;
}
public static class Address {
@@ -146,6 +295,13 @@ public class ParameterAuthorizingModuleTest extends
TestCase {
public String zip;
}
+ /**
+ * Record fixture: forces Jackson to bind {@code city}/{@code secret} via
its creator/constructor
+ * path, the code path {@link
AuthorizingSettableBeanProperty#withValueDeserializer} covers.
+ */
+ public record RecordAddress(String city, String secret) {
+ }
+
/**
* Builder-pattern fixture: forces Jackson to use {@code
BuilderBasedDeserializer},
* which dispatches property deserialization through {@code
SettableBeanProperty.deserializeSetAndReturn}
@@ -172,4 +328,88 @@ public class ParameterAuthorizingModuleTest extends
TestCase {
public ImmutablePerson build() { return new ImmutablePerson(this);
}
}
}
+
+ /** Top-level creator-bound target: no enclosing POJO field, exercises the
outermost readValue(). */
+ public record TopLevelRecord(String name, String secret) {
+ }
+
+ /** @JsonCreator on a static factory method rather than a constructor. */
+ public static class FactoryCreated {
+ public final String name;
+ public final String secret;
+
+ private FactoryCreated(String name, String secret) {
+ this.name = name;
+ this.secret = secret;
+ }
+
+ @JsonCreator
+ public static FactoryCreated of(@JsonProperty("name") String name,
@JsonProperty("secret") String secret) {
+ return new FactoryCreated(name, secret);
+ }
+ }
+
+ /** @ConstructorProperties (java.beans) rather than @JsonCreator. */
+ public static class ConstructorPropsBean {
+ public final String name;
+ public final String secret;
+
+ @ConstructorProperties({"name", "secret"})
+ public ConstructorPropsBean(String name, String secret) {
+ this.name = name;
+ this.secret = secret;
+ }
+ }
+
+ /** Three-level nesting of creator-bound records, to exercise cumulative
path-stack depth. */
+ public record Wrapper(String top, Inner inner) {
+ }
+
+ public record Inner(String mid, InnerInner innerinner) {
+ }
+
+ public record InnerInner(String bottom, String secret) {
+ }
+
+ /** A creator-bound record whose component is a List of further
creator-bound records. */
+ public record WithList(String label, List<Item> items) {
+ }
+
+ public record Item(String value, String secret) {
+ }
+
+ /** A creator-bound record whose component is a Map of further
creator-bound records. */
+ public record WithMap(String label, Map<String, Item> items) {
+ }
+
+ /** A creator-bound record whose component is an array of further
creator-bound records. */
+ public record WithArray(String label, Item[] items) {
+ }
+
+ /** A creator-bound record whose component is a List of an ordinary
field-based POJO. */
+ public record ListOfPlain(String label, List<PlainAddr> addrs) {
+ }
+
+ public static class PlainAddr {
+ public String city;
+ public String zip;
+ }
+
+ /**
+ * A record whose compact constructor enforces a non-null invariant. When
{@code secret} is
+ * redacted by authorization, Jackson substitutes {@code null} for it, and
this constructor
+ * throws -- exercising {@link RedactionAwareDeserializer}'s fail-closed
handling of that
+ * construction failure.
+ */
+ public record Validated(String name, String secret) {
+ public Validated {
+ if (secret == null) {
+ throw new IllegalArgumentException("secret must not be null");
+ }
+ }
+ }
+
+ /** A record with a primitive component, to exercise
FAIL_ON_NULL_FOR_PRIMITIVES interaction. */
+ public record Money(int amount, String currency) {
+ }
}