Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.apache.struts2.inject.Inject;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.text.StringEscapeUtils;
import org.apache.struts2.dispatcher.mapper.ActionMapper;
import org.apache.struts2.dispatcher.mapper.ActionMapping;

Expand Down Expand Up @@ -104,7 +105,7 @@ protected void doExecute(String finalLocation, ActionInvocation invocation) thro

// Render
PrintWriter pw = new PrintWriter(response.getOutputStream());
pw.write("<!DOCTYPE html><html><body><form action=\"" + finalLocation + "\" method=\"POST\">");
pw.write("<!DOCTYPE html><html><body><form action=\"" + StringEscapeUtils.escapeHtml4(finalLocation) + "\" method=\"POST\">");
writeFormElements(request, pw);
writePrologueScript(pw);
pw.write("</html>");
Expand Down
103 changes: 103 additions & 0 deletions core/src/test/java/org/apache/struts2/result/PostbackResultTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -145,5 +145,108 @@ public void testPassingNullInvocation() throws Exception{
}
}

/**
* WW-5623: Verify that HTML special characters in finalLocation are properly
* escaped in the rendered form action attribute to prevent XSS.
*/
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)\"&param=<script>", mockInvocation);

String output = res.getContentAsString();

// The action attribute must contain escaped HTML entities
assertTrue("Double quote should be escaped to &quot;",
output.contains("action=\"/test&quot;onmouseover=&quot;alert(1)&quot;&amp;param=&lt;script&gt;\""));
// Must not contain unescaped double-quote that breaks out of the attribute
assertFalse("Raw double-quote must not appear in action value",
output.contains("action=\"/test\""));

control.verify();
}

/**
* WW-5623: Verify that each individual HTML special character is properly escaped.
*/
public void testFormActionEscapesAllHtmlSpecialChars() throws Exception {
ActionContext context = ActionContext.getContext();
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
context.put(ServletActionContext.HTTP_REQUEST, req);
context.put(ServletActionContext.HTTP_RESPONSE, res);

IMocksControl control = createControl();
ActionInvocation mockInvocation = control.createMock(ActionInvocation.class);
expect(mockInvocation.getInvocationContext()).andReturn(context).anyTimes();

control.replay();

PostbackResult result = new PostbackResult();
result.setActionMapper(container.getInstance(ActionMapper.class));
result.doExecute("/path?a=1&b=2\"<>", mockInvocation);

String output = res.getContentAsString();

assertTrue("Ampersand should be escaped", output.contains("&amp;"));
assertTrue("Double-quote should be escaped", output.contains("&quot;"));
assertTrue("Less-than should be escaped", output.contains("&lt;"));
assertTrue("Greater-than should be escaped", output.contains("&gt;"));

control.verify();
}

/**
* WW-5623: Verify that a clean location (no special chars) renders unchanged.
*/
public void testFormActionCleanLocationUnchanged() throws Exception {
ActionContext context = ActionContext.getContext();
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
context.put(ServletActionContext.HTTP_REQUEST, req);
context.put(ServletActionContext.HTTP_RESPONSE, res);

IMocksControl control = createControl();
ActionInvocation mockInvocation = control.createMock(ActionInvocation.class);
expect(mockInvocation.getInvocationContext()).andReturn(context).anyTimes();

control.replay();

PostbackResult result = new PostbackResult();
result.setActionMapper(container.getInstance(ActionMapper.class));
result.doExecute("/clean/path/action.do", mockInvocation);

String output = res.getContentAsString();

assertTrue("Clean location should render as-is in action attribute",
output.contains("action=\"/clean/path/action.do\""));

control.verify();
}

}