From 133cbd2442ebf65320a111d5b52f1e7c3675787c Mon Sep 17 00:00:00 2001 From: Arun Date: Sat, 11 Jul 2026 00:26:13 +0530 Subject: [PATCH 1/7] Add path segment validation utility to StaticContentLoader --- .../dispatcher/StaticContentLoader.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/core/src/main/java/org/apache/struts2/dispatcher/StaticContentLoader.java b/core/src/main/java/org/apache/struts2/dispatcher/StaticContentLoader.java index 149b18c817..d456c53c2f 100644 --- a/core/src/main/java/org/apache/struts2/dispatcher/StaticContentLoader.java +++ b/core/src/main/java/org/apache/struts2/dispatcher/StaticContentLoader.java @@ -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"); } } } From e68be277c9e321b841dcc6c2195b6b8be371ae72 Mon Sep 17 00:00:00 2001 From: Arun Date: Sat, 11 Jul 2026 00:35:55 +0530 Subject: [PATCH 2/7] Remove redundant URL decode in buildPath and reject malformed path segments early --- .../DefaultStaticContentLoader.java | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java b/core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java index 3b7a64fd22..5ebae30c03 100644 --- a/core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java +++ b/core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java @@ -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; @@ -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); @@ -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; } From 10c3d26dd5fb98296f958030036a4a8a2e92d3e0 Mon Sep 17 00:00:00 2001 From: Arun Date: Sat, 11 Jul 2026 00:42:51 +0530 Subject: [PATCH 3/7] Use shared path segment validation in WebJar URL provider --- .../apache/struts2/webjars/DefaultWebJarUrlProvider.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java b/core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java index 0b99f85b82..158637dc57 100644 --- a/core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java +++ b/core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java @@ -123,12 +123,13 @@ private Optional 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(); } } From fb70bb1c76629c3ae4f0a7696ecd71287905cbaf Mon Sep 17 00:00:00 2001 From: Arun Date: Sat, 11 Jul 2026 00:46:05 +0530 Subject: [PATCH 4/7] Document encoding contract on RequestUtils.getServletPath --- core/src/main/java/org/apache/struts2/RequestUtils.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/src/main/java/org/apache/struts2/RequestUtils.java b/core/src/main/java/org/apache/struts2/RequestUtils.java index 5840ff06b6..651a0a27f5 100644 --- a/core/src/main/java/org/apache/struts2/RequestUtils.java +++ b/core/src/main/java/org/apache/struts2/RequestUtils.java @@ -53,6 +53,11 @@ public class RequestUtils { * Retrieves the current request servlet path. * Deals with differences between servlet specs (2.2 vs 2.3+) * + *

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.

+ * * @param request the request * @return the servlet path */ From 05e205a8f69f3d6643d4e5eb1882abbfba70be7f Mon Sep 17 00:00:00 2001 From: Arun Date: Sat, 11 Jul 2026 00:49:07 +0530 Subject: [PATCH 5/7] Add tests for path segment validation in static content loader --- .../DefaultStaticContentLoaderTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderTest.java b/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderTest.java index db5caba147..537ff91b2c 100644 --- a/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderTest.java +++ b/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderTest.java @@ -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); + } } From bde79089350c8391dda7ab952d20b078409d1e05 Mon Sep 17 00:00:00 2001 From: Arun Date: Sat, 11 Jul 2026 00:51:03 +0530 Subject: [PATCH 6/7] Add encoded traversal test for WebJar static content serving --- .../DefaultStaticContentLoaderWebJarTest.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java b/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java index 388aff42e3..da9f4e6708 100644 --- a/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java +++ b/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java @@ -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); + } } From 8e6298353929e8abdc8a2053954eccbb6bf7136f Mon Sep 17 00:00:00 2001 From: Arun Date: Sat, 11 Jul 2026 00:54:58 +0530 Subject: [PATCH 7/7] Add encoded traversal tests for WebJar URL provider --- .../struts2/webjars/DefaultWebJarUrlProviderTest.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/core/src/test/java/org/apache/struts2/webjars/DefaultWebJarUrlProviderTest.java b/core/src/test/java/org/apache/struts2/webjars/DefaultWebJarUrlProviderTest.java index 6d8d2d37e5..30944469a0 100644 --- a/core/src/test/java/org/apache/struts2/webjars/DefaultWebJarUrlProviderTest.java +++ b/core/src/test/java/org/apache/struts2/webjars/DefaultWebJarUrlProviderTest.java @@ -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");