From aaf286afa832422dcb19826df1b85bd6a4384c3d Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Mon, 25 May 2026 16:38:20 +0200 Subject: [PATCH] WW-5623 fix(core): HTML-encode form action in PostbackResult to prevent XSS (#1701) PostbackResult.doExecute() embeds finalLocation into a
attribute via raw string concatenation. A double quote in the location breaks out of the attribute, enabling reflected XSS. The response Content-Type is text/html. Form field names and values elsewhere in the same class are properly URL-encoded via URLEncoder.encode(); the action attribute was not encoded at all. Wrap finalLocation with StringEscapeUtils.escapeHtml4() before embedding it in the form tag, consistent with the encoding approach used in DefaultActionProxy, Property, and TextProviderHelper. Adds three regression tests in PostbackResultTest: - testFormActionHtmlEscaping: XSS payload with attribute breakout - testFormActionEscapesAllHtmlSpecialChars: covers ", &, <, > - testFormActionCleanLocationUnchanged: regression for clean URLs --- .../apache/struts2/result/PostbackResult.java | 3 +- .../struts2/result/PostbackResultTest.java | 103 ++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/org/apache/struts2/result/PostbackResult.java b/core/src/main/java/org/apache/struts2/result/PostbackResult.java index 96015e8fd..8c4056ca9 100644 --- a/core/src/main/java/org/apache/struts2/result/PostbackResult.java +++ b/core/src/main/java/org/apache/struts2/result/PostbackResult.java @@ -21,6 +21,7 @@ package org.apache.struts2.result; import com.opensymphony.xwork2.ActionContext; import com.opensymphony.xwork2.ActionInvocation; import com.opensymphony.xwork2.inject.Inject; +import org.apache.commons.text.StringEscapeUtils; import org.apache.struts2.dispatcher.mapper.ActionMapper; import org.apache.struts2.dispatcher.mapper.ActionMapping; @@ -101,7 +102,7 @@ public class PostbackResult extends StrutsResultSupport { // Render PrintWriter pw = new PrintWriter(response.getOutputStream()); - pw.write(""); + pw.write(""); writeFormElements(request, pw); writePrologueScript(pw); pw.write(""); diff --git a/core/src/test/java/org/apache/struts2/result/PostbackResultTest.java b/core/src/test/java/org/apache/struts2/result/PostbackResultTest.java index f019436f7..3ec70637f 100644 --- a/core/src/test/java/org/apache/struts2/result/PostbackResultTest.java +++ b/core/src/test/java/org/apache/struts2/result/PostbackResultTest.java @@ -146,5 +146,108 @@ public class PostbackResultTest extends StrutsInternalTestCase { } } + /** + * WW-5623: Verify that HTML special characters in finalLocation are properly + * escaped in the rendered form action attribute. + */ + public void testFormActionHtmlEscaping() throws Exception { + ActionContext context = ActionContext.getContext(); + ValueStack stack = context.getValueStack(); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + context.put(ServletActionContext.HTTP_REQUEST, req); + context.put(ServletActionContext.HTTP_RESPONSE, res); + + // Push an object with a malicious property onto the value stack + stack.push(new Object() { + public String getTargetUrl() { + return "/test\"onmouseover=\"alert(1)"; + } + }); + + PostbackResult result = new PostbackResult(); + result.setLocation("/redirect?url=${targetUrl}"); + result.setPrependServletContext(false); + + IMocksControl control = createControl(); + ActionInvocation mockInvocation = control.createMock(ActionInvocation.class); + expect(mockInvocation.getInvocationContext()).andReturn(context).anyTimes(); + expect(mockInvocation.getStack()).andReturn(stack).anyTimes(); + + control.replay(); + result.setActionMapper(container.getInstance(ActionMapper.class)); + + // Call doExecute directly with a malicious location containing all critical chars + result.doExecute("/test\"onmouseover=\"alert(1)\"¶m=