Skip to content
5 changes: 5 additions & 0 deletions core/src/main/java/org/apache/struts2/RequestUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ public class RequestUtils {
* Retrieves the current request servlet path.
* Deals with differences between servlet specs (2.2 vs 2.3+)
*
* <p>Note: the fallback branch extracts from the raw {@code requestURI}, which may
* retain percent-encoded characters. Callers must not apply additional URL decoding
* to the returned value because the servlet container has already performed decoding
* where applicable.</p>
*
* @param request the request
* @return the servlet path
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URL;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
Expand Down Expand Up @@ -222,6 +220,12 @@ public void findStaticResource(String path, HttpServletRequest request, HttpServ
throws IOException {
String name = cleanupPath(path);

if (Validator.containsMalformedPathSegment(name)) {
LOG.debug("Rejecting static resource request with malformed path segment");
sendNotFound(response);
return;
}

if (name.startsWith(WEBJARS_REQUEST_PREFIX)) {
if (!findWebJarResource(name, path, request, response)) {
sendNotFound(response);
Expand Down Expand Up @@ -353,17 +357,12 @@ protected URL findResource(String path) throws IOException {
* @param name resource name
* @param packagePrefix The package prefix to use to locate the resource
* @return full path
* @throws UnsupportedEncodingException If there is a encoding problem
*/
protected String buildPath(String name, String packagePrefix) throws UnsupportedEncodingException {
String resourcePath;
protected String buildPath(String name, String packagePrefix) {
if (packagePrefix.endsWith("/") && name.startsWith("/")) {
resourcePath = packagePrefix + name.substring(1);
} else {
resourcePath = packagePrefix + name;
return packagePrefix + name.substring(1);
}

return URLDecoder.decode(resourcePath, encoding);
return packagePrefix + name;
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,26 @@ public static String validateStaticContentPath(String uiStaticContentPath) {
LOG.debug("\"{}\" has been set to \"{}\"", StrutsConstants.STRUTS_UI_STATIC_CONTENT_PATH, uiStaticContentPath);
return uiStaticContentPath;
}

/**
* Checks whether the given path contains malformed segments that do not belong in a
* normalised resource path, such as dot-dot sequences, backslash separators, or
* percent-encoded forms that a well-behaved client would never send for static
* resource requests.
*
* @param path the path to check (must not be null)
* @return {@code true} if the path contains a suspicious segment
*/
public static boolean containsMalformedPathSegment(String path) {
if (path.contains("..")) {
return true;
}
if (path.contains("\\")) {
return true;
}
// Percent-encoded dot forms that have no place in a normalised static resource path
String lower = path.toLowerCase(java.util.Locale.ROOT);
return lower.contains("%2e");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,13 @@ private Optional<String[]> split(String logicalPath) {
return Optional.empty();
}
String normalized = StringUtils.stripStart(logicalPath, "/");
if (normalized.contains("\\")) {
if (StaticContentLoader.Validator.containsMalformedPathSegment(normalized)) {
LOG.debug("Rejecting WebJar path with malformed segment: {}", logicalPath);
return Optional.empty();
}
for (String segment : normalized.split("/")) {
if (segment.equals("..") || segment.equals(".")) {
LOG.debug("Rejecting WebJar path with traversal segment: {}", logicalPath);
if (segment.equals(".")) {
LOG.debug("Rejecting WebJar path with dot segment: {}", logicalPath);
return Optional.empty();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,29 @@ protected void setUp() throws Exception {
defaultStaticContentLoader.setEncoding("UTF-8");
defaultStaticContentLoader.setStaticContentPath("/static");
}
public void testBuildPathDoesNotDecodePercentEncoding() {
String result = defaultStaticContentLoader.buildPath("/%2e%2e/secret", "static/");
assertEquals("static/%2e%2e/secret", result);
}

public void testFindStaticResourceRejectsLiteralTraversal() throws Exception {
responseMock.sendError(HttpServletResponse.SC_NOT_FOUND);
expectLastCall();
replay(responseMock);
defaultStaticContentLoader.findStaticResource("/static/../../../etc/passwd", requestMock, responseMock);
}

public void testFindStaticResourceRejectsEncodedTraversal() throws Exception {
responseMock.sendError(HttpServletResponse.SC_NOT_FOUND);
expectLastCall();
replay(responseMock);
defaultStaticContentLoader.findStaticResource("/static/%2e%2e/%2e%2e/secret", requestMock, responseMock);
}

public void testFindStaticResourceRejectsBackslashPath() throws Exception {
responseMock.sendError(HttpServletResponse.SC_NOT_FOUND);
expectLastCall();
replay(responseMock);
defaultStaticContentLoader.findStaticResource("/static/..\\..\\secret", requestMock, responseMock);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,15 @@ public void disabledWebJarsReturns404() throws Exception {

verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
}

@Test
public void webJarEncodedTraversalReturns404() throws Exception {
DefaultStaticContentLoader webJarLoader = newLoader(true);
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);

webJarLoader.findStaticResource("/static/webjars/jquery/%2e%2e/%2e%2e/etc/passwd", request, response);

verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ public void resolveUrlRejectsTraversal() {
assertThat(provider.resolveUrl("../jquery/jquery.min.js", request)).isEmpty();
}

@Test
public void encodedTraversalIsRejected() {
assertThat(provider.resolveResourcePath("jquery/%2e%2e/%2e%2e/etc/passwd")).isEmpty();
assertThat(provider.resolveResourcePath("jquery/%2E%2E/secret")).isEmpty();
}

@Test
public void resolveUrlRejectsEncodedTraversal() {
assertThat(provider.resolveUrl("jquery/%2e%2e/%2e%2e/etc/passwd", request)).isEmpty();
}

@Test
public void allowlistBlocksNonListedWebjar() {
provider.setAllowlist("bootstrap");
Expand Down