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 349314a3a WW-5748 fix(rest): let Jackson XML's deserializer modifier 
see the bean deserializer (#1947)
349314a3a is described below

commit 349314a3af5366a68631f65d80bdfb31d3ab4448
Author: Lukasz Lenart <[email protected]>
AuthorDate: Tue Sep 15 18:45:07 2026 +0200

    WW-5748 fix(rest): let Jackson XML's deserializer modifier see the bean 
deserializer (#1947)
    
    JacksonXmlHandler registered ParameterAuthorizingModule on an XmlMapper
    whose constructor had already registered JacksonXmlModule. A module's
    deserializer modifier is inserted at the head of the list, so the
    authorizing modifier ran first and handed Jackson XML's modifier a
    RedactionAwareDeserializer, which fails its instanceof
    BeanDeserializerBase test: the XML wrapper that reads an unwrapped list
    (@JacksonXmlElementWrapper(useWrapping = false)) was never installed,
    and every such list failed to deserialize through the XML handler,
    authorization context or not, since the module was introduced.
    
    The handler now builds the XmlMapper without a module and registers
    JacksonXmlModule after ParameterAuthorizingModule, so the XML modifier
    runs first and the authorizing wrapper goes around its result. The
    module's Javadoc states the order for handlers that register it
    themselves.
    
    With Jackson XML's wrapper now inside the authorizing one, the
    per-property @JsonIdentityInfo reader rebuild in
    RedactionAwareDeserializer.createContextual (WW-5746) walks the
    delegating wrappers down to the bean. Jackson XML's wrapper cannot take
    a new delegatee, so it is rebuilt around the bean and contextualized
    with a null property, which recomputes its unwrapped names without
    building the id reader over again; other delegating wrappers get the
    rebuilt bean through replaceDelegatee. The wrapper only stays around a
    bean that has an unwrapped list, so the combination is exercised by a
    bean carrying both. jackson-dataformat-xml is optional for the plugin,
    so the class naming its wrapper is loaded only once it is known to be
    present.
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../struts2/rest/handler/JacksonXmlHandler.java    |   7 +-
 .../jackson/ParameterAuthorizingModule.java        |   4 +-
 .../jackson/RedactionAwareDeserializer.java        |  32 ++++--
 .../rest/handler/jackson/XmlWrapperSupport.java    |  78 ++++++++++++++
 .../rest/handler/JacksonXmlHandlerTest.java        | 119 +++++++++++++++++++++
 .../jackson/ParameterAuthorizingModuleTest.java    |  16 ++-
 6 files changed, 244 insertions(+), 12 deletions(-)

diff --git 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JacksonXmlHandler.java
 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JacksonXmlHandler.java
index a25b151aa..cfeddee58 100644
--- 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JacksonXmlHandler.java
+++ 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/JacksonXmlHandler.java
@@ -19,6 +19,8 @@
 package org.apache.struts2.rest.handler;
 
 import com.fasterxml.jackson.databind.ObjectReader;
+import com.fasterxml.jackson.dataformat.xml.JacksonXmlModule;
+import com.fasterxml.jackson.dataformat.xml.XmlFactory;
 import com.fasterxml.jackson.dataformat.xml.XmlMapper;
 import org.apache.commons.lang3.BooleanUtils;
 import org.apache.struts2.ActionInvocation;
@@ -44,8 +46,11 @@ public class JacksonXmlHandler implements 
AuthorizationAwareContentTypeHandler {
     private final ParameterAuthorizingModule parameterAuthorizingModule = new 
ParameterAuthorizingModule();
 
     public JacksonXmlHandler() {
-        mapper = new XmlMapper();
+        // Deserializer modifiers run in reverse registration order; Jackson 
XML's must see Jackson's
+        // own bean deserializer, so it is registered after the authorizing 
module.
+        mapper = new XmlMapper(new XmlFactory(), null);
         mapper.registerModule(parameterAuthorizingModule);
+        mapper.registerModule(new JacksonXmlModule());
     }
 
     @Override
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 89ac2f2f2..b9aeae4b6 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
@@ -43,7 +43,9 @@ import java.util.List;
  * external name a {@code @JsonProperty} or naming strategy puts on the wire, 
since the authorizer
  * resolves the path against the member.
  *
- * <p>Register this module once on each handler's mapper (e.g. in the 
constructor). All per-request
+ * <p>Register this module once on each handler's mapper (e.g. in the 
constructor), and before any
+ * format module whose deserializer modifier expects Jackson's own bean 
deserializer: modifiers run in
+ * reverse registration order, and this one wraps the bean deserializer it is 
given. All per-request
  * authorization state is read from the ThreadLocal context, so the module + 
mapper combination is
  * thread-safe and reusable across requests.</p>
  *
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 dd1835523..512410fd2 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
@@ -81,15 +81,35 @@ final class RedactionAwareDeserializer extends 
DelegatingDeserializer {
     public JsonDeserializer<?> createContextual(DeserializationContext ctxt, 
BeanProperty property)
             throws JsonMappingException {
         JsonDeserializer<?> contextual = super.createContextual(ctxt, 
property);
-        JsonDeserializer<?> bean = ((DelegatingDeserializer) 
contextual).getDelegatee();
-        if (bean instanceof BeanDeserializerBase beanDeserializer && 
beanDeserializer.getObjectIdReader() != null) {
-            ObjectIdReader reader = beanDeserializer.getObjectIdReader();
+        JsonDeserializer<?> delegatee = ((DelegatingDeserializer) 
contextual).getDelegatee();
+        JsonDeserializer<?> authorized = withAuthorizedObjectIdReader(ctxt, 
delegatee);
+        return authorized == delegatee ? contextual : new 
RedactionAwareDeserializer(authorized);
+    }
+
+    /**
+     * The bean deserializer may sit under further delegating wrappers by 
then, so the rebuilt one
+     * is put back through them.
+     */
+    private static JsonDeserializer<?> 
withAuthorizedObjectIdReader(DeserializationContext ctxt,
+                                                                   
JsonDeserializer<?> deserializer)
+            throws JsonMappingException {
+        if (deserializer instanceof BeanDeserializerBase bean && 
bean.getObjectIdReader() != null) {
+            ObjectIdReader reader = bean.getObjectIdReader();
             ObjectIdReader authorized = 
ParameterAuthorizingModule.authorizedObjectIdReader(reader);
-            if (authorized != reader) {
-                return new 
RedactionAwareDeserializer(beanDeserializer.withObjectIdReader(authorized));
+            return authorized == reader ? bean : 
bean.withObjectIdReader(authorized);
+        }
+        if (deserializer instanceof DelegatingDeserializer delegating) {
+            JsonDeserializer<?> inner = delegating.getDelegatee();
+            JsonDeserializer<?> authorized = 
withAuthorizedObjectIdReader(ctxt, inner);
+            if (authorized == inner) {
+                return delegating;
+            }
+            if (XmlWrapperSupport.isUnwrappedListWrapper(delegating)) {
+                return XmlWrapperSupport.rebuildAround(ctxt, 
(BeanDeserializerBase) authorized);
             }
+            return delegating.replaceDelegatee(authorized);
         }
-        return contextual;
+        return deserializer;
     }
 
     /**
diff --git 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/XmlWrapperSupport.java
 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/XmlWrapperSupport.java
new file mode 100644
index 000000000..42ff9e47a
--- /dev/null
+++ 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/jackson/XmlWrapperSupport.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.struts2.rest.handler.jackson;
+
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.deser.BeanDeserializerBase;
+import com.fasterxml.jackson.dataformat.xml.deser.WrapperHandlingDeserializer;
+
+/**
+ * Jackson XML's wrapper for a bean with an unwrapped list cannot take a new 
delegatee; it recomputes
+ * itself from the bean it is given when contextualized. {@code 
jackson-dataformat-xml} is optional
+ * for the plugin, so the class that names it is only loaded once it is known 
to be present.
+ */
+final class XmlWrapperSupport {
+
+    private static final boolean AVAILABLE = available();
+
+    private XmlWrapperSupport() {
+        // utility
+    }
+
+    private static boolean available() {
+        try {
+            
Class.forName("com.fasterxml.jackson.dataformat.xml.deser.WrapperHandlingDeserializer",
+                    false, XmlWrapperSupport.class.getClassLoader());
+            return true;
+        } catch (ClassNotFoundException | LinkageError absent) {
+            return false;
+        }
+    }
+
+    static boolean isUnwrappedListWrapper(JsonDeserializer<?> deserializer) {
+        return AVAILABLE && Xml.isUnwrappedListWrapper(deserializer);
+    }
+
+    /**
+     * A {@code null} property keeps the re-contextualization from building 
the bean's id reader
+     * over again; the property-specific state is already on the bean from its 
first one.
+     */
+    static <T> JsonDeserializer<T> rebuildAround(DeserializationContext ctxt, 
BeanDeserializerBase bean)
+            throws JsonMappingException {
+        return Xml.rebuildAround(ctxt, bean);
+    }
+
+    private static final class Xml {
+
+        private Xml() {
+        }
+
+        static boolean isUnwrappedListWrapper(JsonDeserializer<?> 
deserializer) {
+            return deserializer instanceof WrapperHandlingDeserializer;
+        }
+
+        @SuppressWarnings("unchecked")
+        static <T> JsonDeserializer<T> rebuildAround(DeserializationContext 
ctxt, BeanDeserializerBase bean)
+                throws JsonMappingException {
+            return (JsonDeserializer<T>) new 
WrapperHandlingDeserializer(bean).createContextual(ctxt, null);
+        }
+    }
+}
diff --git 
a/plugins/rest/src/test/java/org/apache/struts2/rest/handler/JacksonXmlHandlerTest.java
 
b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/JacksonXmlHandlerTest.java
index 69fc15380..fd2bb52d5 100644
--- 
a/plugins/rest/src/test/java/org/apache/struts2/rest/handler/JacksonXmlHandlerTest.java
+++ 
b/plugins/rest/src/test/java/org/apache/struts2/rest/handler/JacksonXmlHandlerTest.java
@@ -18,8 +18,15 @@
  */
 package org.apache.struts2.rest.handler;
 
+import com.fasterxml.jackson.annotation.JsonIdentityInfo;
+import com.fasterxml.jackson.annotation.ObjectIdGenerators;
+import 
com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlText;
 import org.apache.struts2.ActionInvocation;
 import org.apache.struts2.XWorkTestCase;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
+import org.apache.struts2.interceptor.parameter.ParameterAuthorizer;
 import org.apache.struts2.mock.MockActionInvocation;
 
 import java.io.Reader;
@@ -27,6 +34,8 @@ import java.io.StringReader;
 import java.io.StringWriter;
 import java.io.Writer;
 import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -52,6 +61,21 @@ public class JacksonXmlHandlerTest extends XWorkTestCase {
         ai = new MockActionInvocation();
     }
 
+    @Override
+    public void tearDown() throws Exception {
+        ParameterAuthorizationContext.unbind();
+        super.tearDown();
+    }
+
+    private void bind(ParameterAuthorizer authorizer, Object target) {
+        ParameterAuthorizationContext.bind(authorizer, target, target);
+    }
+
+    private <T> T read(String body, T target) throws Exception {
+        handler.toObject(ai, new StringReader(body), target);
+        return target;
+    }
+
     public void testObjectToXml() throws Exception {
         // given
         SimpleBean obj = new SimpleBean();
@@ -92,4 +116,99 @@ public class JacksonXmlHandlerTest extends XWorkTestCase {
                 .containsExactly("Adam", "Ewa");
     }
 
+    public void testUnwrappedListWithoutContext() throws Exception {
+        // Jackson XML's deserializer modifier must see Jackson's own bean 
deserializer, not the
+        // authorization wrapper, to install its unwrapped-list handling.
+        UnwrappedListBean bean = 
read("<bean><items>a</items><items>b</items><name>n</name></bean>",
+                new UnwrappedListBean());
+        assertEquals(List.of("a", "b"), bean.items);
+        assertEquals("n", bean.name);
+    }
+
+    public void testUnwrappedListAuthorized() throws Exception {
+        Set<String> granted = Set.of("items", "items[0]", "name");
+        bind((path, t, a) -> granted.contains(path), new UnwrappedListBean());
+        UnwrappedListBean bean = 
read("<bean><items>a</items><items>b</items><name>n</name></bean>",
+                new UnwrappedListBean());
+        assertEquals(List.of("a", "b"), bean.items);
+        assertEquals("n", bean.name);
+    }
+
+    public void testUnwrappedListRejected() throws Exception {
+        Set<String> granted = Set.of("name");
+        bind((path, t, a) -> granted.contains(path), new UnwrappedListBean());
+        UnwrappedListBean bean = 
read("<bean><items>a</items><items>b</items><name>n</name></bean>",
+                new UnwrappedListBean());
+        assertNull(bean.items);
+        assertEquals("n", bean.name);
+    }
+
+    public void testXmlTextStillReads() throws Exception {
+        TextBean bean = read("<bean><attr>x</attr>hello</bean>", new 
TextBean());
+        assertEquals("hello", bean.text);
+        assertEquals("x", bean.attr);
+    }
+
+    public void testSoleXmlTextWithAttributeReadsAndIsAuthorized() throws 
Exception {
+        // A sole text property next to an attribute goes through Jackson 
XML's text deserializer,
+        // which the reorder now installs; it must still write through the 
authorizing property.
+        TextAttributeBean bean = read("<bean attr=\"x\">hello</bean>", new 
TextAttributeBean());
+        assertEquals("hello", bean.text);
+        assertEquals("x", bean.attr);
+
+        Set<String> granted = Set.of("attr");
+        bind((path, t, a) -> granted.contains(path), new TextAttributeBean());
+        TextAttributeBean rejected = read("<bean attr=\"x\">hello</bean>", new 
TextAttributeBean());
+        assertNull(rejected.text);
+        assertEquals("x", rejected.attr);
+    }
+
+    public void 
testBeanTypedObjectIdDeclaredOnTheReferencingPropertyAuthorizedUnderTheIdPath() 
throws Exception {
+        // The per-property reader is rebuilt in createContextual through the 
XML wrapper Jackson
+        // keeps around a bean with an unwrapped list, inside the redaction 
wrapper.
+        Set<String> granted = Set.of("child", "child.id", "child.k", 
"child.name", "child.tags", "child.tags[0]");
+        bind((path, t, a) -> granted.contains(path), new 
KeyIdentifiedHolder());
+        KeyIdentifiedHolder holder = read(
+                
"<holder><child><id><k>x</k></id><name>alice</name><tags>t</tags></child></holder>",
+                new KeyIdentifiedHolder());
+        assertEquals("alice", holder.child.name);
+        assertEquals(List.of("t"), holder.child.tags);
+        assertNull("id member authorized by the referring bean's grant for 
[child.k] ?", holder.child.id.k);
+    }
+
+    public static class UnwrappedListBean {
+        @JacksonXmlElementWrapper(useWrapping = false)
+        public List<String> items;
+        public String name;
+    }
+
+    public static class TextBean {
+        @JacksonXmlText
+        public String text;
+        public String attr;
+    }
+
+    public static class TextAttributeBean {
+        @JacksonXmlText
+        public String text;
+        @JacksonXmlProperty(isAttribute = true)
+        public String attr;
+    }
+
+    public static class Key {
+        public String k;
+    }
+
+    public static class PlainKeyed {
+        public Key id;
+        public String k;
+        public String name;
+        @JacksonXmlElementWrapper(useWrapping = false)
+        public List<String> tags;
+    }
+
+    public static class KeyIdentifiedHolder {
+        @JsonIdentityInfo(generator = 
ObjectIdGenerators.PropertyGenerator.class, property = "id")
+        public PlainKeyed child;
+    }
 }
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 c0837a8e4..55c2ab8ba 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
@@ -47,6 +47,8 @@ import 
com.fasterxml.jackson.databind.deser.impl.ReadableObjectId;
 import com.fasterxml.jackson.databind.exc.InvalidDefinitionException;
 import com.fasterxml.jackson.databind.module.SimpleModule;
 import com.fasterxml.jackson.databind.util.TokenBuffer;
+import com.fasterxml.jackson.dataformat.xml.JacksonXmlModule;
+import com.fasterxml.jackson.dataformat.xml.XmlFactory;
 import com.fasterxml.jackson.dataformat.xml.XmlMapper;
 import junit.framework.TestCase;
 import org.apache.struts2.interceptor.parameter.ParameterAuthorizationContext;
@@ -264,8 +266,7 @@ public class ParameterAuthorizingModuleTest extends 
TestCase {
     public void testXmlAnySetterPreservesNumericTextRoundTrip() throws 
Exception {
         String number = "1.2345678901234567890123456789";
         for (boolean useBigDecimal : new boolean[]{false, true}) {
-            XmlMapper xmlMapper = new XmlMapper();
-            xmlMapper.registerModule(new ParameterAuthorizingModule(true));
+            XmlMapper xmlMapper = enforcingXmlMapper();
             
xmlMapper.configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, 
useBigDecimal);
             bind((path, t, a) -> false, new DynamicScalarAnySetterBean());
             DynamicScalarAnySetterBean result = xmlMapper.readValue(
@@ -408,8 +409,7 @@ public class ParameterAuthorizingModuleTest extends 
TestCase {
     }
 
     public void testXmlAnySetterUsesSameOptIn() throws Exception {
-        XmlMapper xmlMapper = new XmlMapper();
-        xmlMapper.registerModule(new ParameterAuthorizingModule(true));
+        XmlMapper xmlMapper = enforcingXmlMapper();
 
         bind((path, t, a) -> false, new DynamicScalarAnySetterBean());
         DynamicScalarAnySetterBean allowed = xmlMapper.readValue(
@@ -1040,6 +1040,14 @@ public class ParameterAuthorizingModuleTest extends 
TestCase {
         return new ObjectMapper().registerModule(new 
ParameterAuthorizingModule(true));
     }
 
+    /** Built the way {@code JacksonXmlHandler} builds its mapper: the XML 
module registered last. */
+    private XmlMapper enforcingXmlMapper() {
+        XmlMapper xmlMapper = new XmlMapper(new XmlFactory(), null);
+        xmlMapper.registerModule(new ParameterAuthorizingModule(true));
+        xmlMapper.registerModule(new JacksonXmlModule());
+        return xmlMapper;
+    }
+
     public static class Person {
         public String name;
         public String role;

Reply via email to