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


Reply via email to