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 8f3b4abde WW-5789 fix(rest): keep params-bound values when the body 
handler binds nothing
8f3b4abde is described below

commit 8f3b4abde5672dee11f0467fc5145fd65420a466
Author: Lukasz Lenart <[email protected]>
AuthorDate: Thu Oct 22 21:58:35 2026 +0200

    WW-5789 fix(rest): keep params-bound values when the body handler binds 
nothing
    
    Since WW-5626 (7.2.0), ContentTypeInterceptor runs a two-phase copy for 
every
    handler that is not AuthorizationAwareContentTypeHandler when
    struts.parameters.requireAnnotations=true: a fresh instance is built with 
the
    no-arg constructor, the handler fills it, and every non-null property is 
copied
    onto the real target. FormUrlEncodedHandler, MultipartFormDataHandler and
    HtmlHandler never touch the body, so the "fresh" instance holds only
    constructor defaults, and those overwrote the values ParametersInterceptor 
had
    bound one step earlier. Null defaults were skipped, which is why the report
    reads "works unless the field is null by default".
    
    The three handlers bind nothing, so they now declare the marker and take the
    direct path. The marker Javadoc admits that case and names the subclass 
caveat.
    
    Co-Authored-By: Claude Mythos 5.1 <[email protected]>
---
 .../AuthorizationAwareContentTypeHandler.java      |  6 ++
 .../rest/handler/FormUrlEncodedHandler.java        |  4 +-
 .../apache/struts2/rest/handler/HtmlHandler.java   |  4 +-
 .../rest/handler/MultipartFormDataHandler.java     |  4 +-
 .../ContentTypeInterceptorIntegrationTest.java     | 68 ++++++++++++++++++++--
 5 files changed, 79 insertions(+), 7 deletions(-)

diff --git 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/AuthorizationAwareContentTypeHandler.java
 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/AuthorizationAwareContentTypeHandler.java
index 0e01c507e..a7840b648 100644
--- 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/AuthorizationAwareContentTypeHandler.java
+++ 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/AuthorizationAwareContentTypeHandler.java
@@ -38,6 +38,12 @@ package org.apache.struts2.rest.handler;
  * interface but its parser does not honor the context, authorization will 
silently do nothing —
  * a serious security bug. The marker interface is the contract; 
implementations must uphold it.</p>
  *
+ * <p>A handler whose {@code toObject} binds nothing from the body, such as 
{@link FormUrlEncodedHandler},
+ * upholds the contract trivially and declares the interface so that the 
two-phase copy, which would
+ * write a fresh instance's constructor defaults over values already bound, 
does not run for it. A
+ * subclass that overrides {@code toObject} to deserialize inherits the 
declaration and must honor the
+ * context itself.</p>
+ *
  * @since 7.2.0
  */
 public interface AuthorizationAwareContentTypeHandler extends 
ContentTypeHandler {
diff --git 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/FormUrlEncodedHandler.java
 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/FormUrlEncodedHandler.java
index e73d336a3..076970427 100644
--- 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/FormUrlEncodedHandler.java
+++ 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/FormUrlEncodedHandler.java
@@ -31,10 +31,12 @@ import java.io.Writer;
  *
  * This handler is intended for requests only, not for responses
  *
+ * Binds nothing from the body, so it is trivially {@link 
AuthorizationAwareContentTypeHandler}.
+ *
  * @see <a 
href="http://www.w3.org/TR/html401/interact/forms.html#h-17.13.4";>http://www.w3.org/TR/html401/interact/forms.html#h-17.13.4</a>
  *
  */
-public class FormUrlEncodedHandler implements ContentTypeHandler {
+public class FormUrlEncodedHandler implements 
AuthorizationAwareContentTypeHandler {
 
     public static final String CONTENT_TYPE = 
"application/x-www-form-urlencoded";
 
diff --git 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/HtmlHandler.java 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/HtmlHandler.java
index d7d6e08ed..c86a9b8d6 100644
--- 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/HtmlHandler.java
+++ 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/HtmlHandler.java
@@ -26,8 +26,10 @@ import java.io.Writer;
 
 /**
  * Handles HTML content, usually just a simple passthrough to the framework
+ *
+ * Binds nothing from the body, so it is trivially {@link 
AuthorizationAwareContentTypeHandler}.
  */
-public class HtmlHandler implements ContentTypeHandler {
+public class HtmlHandler implements AuthorizationAwareContentTypeHandler {
 
     @Override
     public String fromObject(ActionInvocation invocation, Object obj, String 
resultCode, Writer out) throws IOException {
diff --git 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/MultipartFormDataHandler.java
 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/MultipartFormDataHandler.java
index 6a4a42b89..3b77ebe6d 100644
--- 
a/plugins/rest/src/main/java/org/apache/struts2/rest/handler/MultipartFormDataHandler.java
+++ 
b/plugins/rest/src/main/java/org/apache/struts2/rest/handler/MultipartFormDataHandler.java
@@ -32,9 +32,11 @@ import java.io.Writer;
  *
  * This handler is intended for requests only, not for responses
  *
+ * Binds nothing from the body, so it is trivially {@link 
AuthorizationAwareContentTypeHandler}.
+ *
  * @see <a 
href="http://www.w3.org/TR/html401/interact/forms.html#h-17.13.4";>http://www.w3.org/TR/html401/interact/forms.html#h-17.13.4</a>
  */
-public class MultipartFormDataHandler implements ContentTypeHandler {
+public class MultipartFormDataHandler implements 
AuthorizationAwareContentTypeHandler {
 
     public static final String CONTENT_TYPE = "multipart/form-data";
 
diff --git 
a/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
 
b/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
index f2a973596..68a875e61 100644
--- 
a/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
+++ 
b/plugins/rest/src/test/java/org/apache/struts2/rest/ContentTypeInterceptorIntegrationTest.java
@@ -26,6 +26,7 @@ import junit.framework.TestCase;
 import org.apache.struts2.ActionContext;
 import org.apache.struts2.ActionInvocation;
 import org.apache.struts2.ActionSupport;
+import org.apache.struts2.ModelDriven;
 import org.apache.struts2.action.Action;
 import org.apache.struts2.dispatcher.mapper.ActionMapping;
 import org.apache.struts2.interceptor.parameter.StrutsParameter;
@@ -37,9 +38,13 @@ import org.apache.struts2.ognl.StrutsOgnlGuard;
 import org.apache.struts2.ognl.StrutsProxyCacheFactory;
 import org.apache.struts2.rest.handler.AuthorizationAwareContentTypeHandler;
 import org.apache.struts2.rest.handler.ContentTypeHandler;
+import org.apache.struts2.rest.handler.FormUrlEncodedHandler;
+import org.apache.struts2.rest.handler.HtmlHandler;
 import org.apache.struts2.rest.handler.JacksonJsonHandler;
+import org.apache.struts2.rest.handler.MultipartFormDataHandler;
 import org.apache.struts2.rest.handler.jackson.ForwardReferenceStateProbe;
 import org.apache.struts2.util.StrutsProxyService;
+import org.apache.struts2.util.ValueStack;
 import org.springframework.mock.web.MockHttpServletRequest;
 
 import java.io.Reader;
@@ -58,6 +63,7 @@ public class ContentTypeInterceptorIntegrationTest extends 
TestCase {
 
     private ContentTypeInterceptor interceptor;
     private SecureRestAction action;
+    private Object configuredAction;
     private Mock mockActionInvocation;
     private Mock mockSelector;
 
@@ -79,6 +85,7 @@ public class ContentTypeInterceptorIntegrationTest extends 
TestCase {
     }
 
     private void setupInterceptorWithHandler(Object actionInstance, 
ContentTypeHandler handler) {
+        configuredAction = actionInstance;
         var ognlUtil = new OgnlUtil(
                 new DefaultOgnlExpressionCacheFactory<>("1000", 
LRU.toString()),
                 new DefaultOgnlBeanInfoCacheFactory<>("1000", LRU.toString()),
@@ -108,14 +115,23 @@ public class ContentTypeInterceptorIntegrationTest 
extends TestCase {
     }
 
     private void runWithBody(String body) throws Exception {
+        runWithBody(body, "application/json");
+    }
+
+    private void runWithBody(String body, String contentType) throws Exception 
{
         MockHttpServletRequest request = new MockHttpServletRequest();
         request.setContent(body.getBytes());
-        request.setContentType("application/json");
+        request.setContentType(contentType);
 
-        ActionContext.of()
+        ActionContext context = ActionContext.of()
                 .withActionMapping(new ActionMapping())
-                .withServletRequest(request)
-                .bind();
+                .withServletRequest(request);
+        if (configuredAction instanceof ModelDriven<?> modelDriven) {
+            Mock mockStack = new Mock(ValueStack.class);
+            mockStack.matchAndReturn("peek", modelDriven.getModel());
+            context.withValueStack((ValueStack) mockStack.proxy());
+        }
+        context.bind();
 
         interceptor.intercept((ActionInvocation) mockActionInvocation.proxy());
         mockSelector.verify();
@@ -159,6 +175,32 @@ public class ContentTypeInterceptorIntegrationTest extends 
TestCase {
                 action.getShallowAddress().getZip());
     }
 
+    // --- WW-5789: a handler that binds nothing must leave the model as the 
params interceptor left it ---
+
+    public void testFormUrlEncodedBodyKeepsValuesBoundOnTheModel() throws 
Exception {
+        assertNoOpHandlerKeepsBoundModelValues(new FormUrlEncodedHandler());
+    }
+
+    public void testMultipartFormDataBodyKeepsValuesBoundOnTheModel() throws 
Exception {
+        assertNoOpHandlerKeepsBoundModelValues(new MultipartFormDataHandler());
+    }
+
+    public void testHtmlBodyKeepsValuesBoundOnTheModel() throws Exception {
+        assertNoOpHandlerKeepsBoundModelValues(new HtmlHandler());
+    }
+
+    private void assertNoOpHandlerKeepsBoundModelValues(ContentTypeHandler 
handler) throws Exception {
+        ModelDrivenRestAction modelDriven = new ModelDrivenRestAction();
+        DefaultedModel model = modelDriven.getModel();
+        model.setStatus("bound-by-params");
+        setupInterceptorWithHandler(modelDriven, handler);
+
+        runWithBody("status=bound-by-params", handler.getContentType());
+
+        assertEquals("a constructor default must not overwrite a value the 
params interceptor bound",
+                "bound-by-params", model.getStatus());
+    }
+
     // --- Tests proving the new Jackson authorization path is in use ---
 
     public void testJacksonHandlerDoesNotRequireNoArgConstructor() throws 
Exception {
@@ -373,4 +415,22 @@ public class ContentTypeInterceptorIntegrationTest extends 
TestCase {
         @JsonProperty("name")
         public void setAdmin(String admin) { this.admin = admin; }
     }
+
+    /**
+     * WW-5789: a model whose constructor sets a default, the shape on which a 
fresh-instance copy shows up.
+     */
+    public static class DefaultedModel {
+        private String status = "new";
+
+        public String getStatus() { return status; }
+
+        public void setStatus(String status) { this.status = status; }
+    }
+
+    public static class ModelDrivenRestAction extends ActionSupport implements 
ModelDriven<DefaultedModel> {
+        private final DefaultedModel model = new DefaultedModel();
+
+        @Override
+        public DefaultedModel getModel() { return model; }
+    }
 }

Reply via email to