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 b70ecc8e1 WW-5645 Canonicalise static content paths and remove
redundant URL decode (#1777)
b70ecc8e1 is described below
commit b70ecc8e15df9e5cb734be2d87cc3e24a99a78ef
Author: Arun <[email protected]>
AuthorDate: Tue Jul 14 15:07:21 2026 +0530
WW-5645 Canonicalise static content paths and remove redundant URL decode
(#1777)
* Add path segment validation utility to StaticContentLoader
* Remove redundant URL decode in buildPath and reject malformed path
segments early
* Use shared path segment validation in WebJar URL provider
* Document encoding contract on RequestUtils.getServletPath
* Add tests for path segment validation in static content loader
* Add encoded traversal test for WebJar static content serving
* Add encoded traversal tests for WebJar URL provider
* Fix missing closing brace in StaticContentLoader causing compile failure
* Use per-segment matching in containsMalformedPathSegment to avoid false
positives
* Remove redundant dot-segment check now handled by
containsMalformedPathSegment
* Fix indentation on validateStaticContentPath closing brace
* Remove unused encoding field and setter from DefaultStaticContentLoader
* Replace denylist with path canonicalisation in Validator
* Wire canonicalisePath into static content serving
* Wire canonicalisePath into WebJar URL provider
* Update tests for canonicalise approach and remove unused setEncoding call
* Remove setEncoding calls from tests to match updated
DefaultStaticContentLoader
* Remove setEncoding calls from tests to match updated
DefaultStaticContentLoader
* Remove redundant encoded-traversal tests per maintainer review —
end-to-end 404 already covered
---
.../main/java/org/apache/struts2/RequestUtils.java | 5 +++
.../dispatcher/DefaultStaticContentLoader.java | 38 +++++++---------------
.../struts2/dispatcher/StaticContentLoader.java | 31 +++++++++++++++++-
.../struts2/webjars/DefaultWebJarUrlProvider.java | 11 +++----
.../dispatcher/DefaultStaticContentLoaderTest.java | 19 ++++++++++-
.../DefaultStaticContentLoaderWebJarTest.java | 12 ++++++-
.../dispatcher/StaticContentLoaderTest.java | 2 --
7 files changed, 80 insertions(+), 38 deletions(-)
diff --git a/core/src/main/java/org/apache/struts2/RequestUtils.java
b/core/src/main/java/org/apache/struts2/RequestUtils.java
index 5840ff06b..651a0a27f 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+)
*
+ * <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
*/
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 3b7a64fd2..f9f83a5fd 100644
---
a/core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java
+++
b/core/src/main/java/org/apache/struts2/dispatcher/DefaultStaticContentLoader.java
@@ -32,16 +32,14 @@ import org.apache.struts2.webjars.WebJarUrlProvider;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
-import java.io.UnsupportedEncodingException;
+import java.util.Optional;
import java.net.URL;
-import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
-import java.util.Optional;
import java.util.StringTokenizer;
/**
@@ -105,11 +103,6 @@ public class DefaultStaticContentLoader implements
StaticContentLoader {
*/
protected final Calendar lastModifiedCal = Calendar.getInstance();
- /**
- * Store state of StrutsConstants.STRUTS_I18N_ENCODING setting.
- */
- protected String encoding;
-
protected boolean devMode;
protected WebJarUrlProvider webJarUrlProvider;
@@ -145,16 +138,6 @@ public class DefaultStaticContentLoader implements
StaticContentLoader {
this.serveStaticBrowserCache =
BooleanUtils.toBoolean(serveStaticBrowserCache);
}
- /**
- * Modify state of StrutsConstants.STRUTS_I18N_ENCODING setting.
- *
- * @param encoding New setting
- */
- @Inject(StrutsConstants.STRUTS_I18N_ENCODING)
- public void setEncoding(String encoding) {
- this.encoding = encoding;
- }
-
@Inject(StrutsConstants.STRUTS_DEVMODE)
public void setDevMode(String devMode) {
this.devMode = Boolean.parseBoolean(devMode);
@@ -222,6 +205,14 @@ public class DefaultStaticContentLoader implements
StaticContentLoader {
throws IOException {
String name = cleanupPath(path);
+ Optional<String> canonical = Validator.canonicalisePath(name);
+ if (canonical.isEmpty()) {
+ LOG.debug("Rejecting static resource request: path escapes
intended scope");
+ sendNotFound(response);
+ return;
+ }
+ name = "/" + canonical.get();
+
if (name.startsWith(WEBJARS_REQUEST_PREFIX)) {
if (!findWebJarResource(name, path, request, response)) {
sendNotFound(response);
@@ -353,17 +344,12 @@ public class DefaultStaticContentLoader implements
StaticContentLoader {
* @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;
}
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 149b18c81..31af4f4ad 100644
--- a/core/src/main/java/org/apache/struts2/dispatcher/StaticContentLoader.java
+++ b/core/src/main/java/org/apache/struts2/dispatcher/StaticContentLoader.java
@@ -27,7 +27,9 @@ import org.apache.struts2.config.StrutsBeanSelectionProvider;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
-
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Optional;
/**
* Interface for loading static resources, based on a path. After implementing
your own static content loader
* you must tell the framework how to use it, eg.
@@ -99,5 +101,32 @@ public interface StaticContentLoader {
return uiStaticContentPath;
}
}
+ /**
+ * Normalises a resource path by resolving {@code .} and {@code ..}
+ * segments and converting backslash separators to forward slashes.
+ *
+ * <p>Returns {@link Optional#empty()} if the resolved path would
+ * escape above the root (i.e. more {@code ..} segments than
+ * preceding path components).</p>
+ *
+ * @param path the raw path to normalise (must not be null)
+ * @return the canonical path without leading slash, or empty if
+ * the path escapes above the root
+ */
+ public static Optional<String> canonicalisePath(String path) {
+ String normalised = path.replace('\\', '/');
+ Deque<String> segments = new ArrayDeque<>();
+ for (String segment : normalised.split("/", -1)) {
+ if ("..".equals(segment)) {
+ if (segments.isEmpty()) {
+ return Optional.empty();
+ }
+ segments.removeLast();
+ } else if (!".".equals(segment) && !segment.isEmpty()) {
+ segments.addLast(segment);
+ }
+ }
+ return Optional.of(String.join("/", segments));
+ }
}
}
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 0b99f85b8..892ccd47f 100644
---
a/core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java
+++
b/core/src/main/java/org/apache/struts2/webjars/DefaultWebJarUrlProvider.java
@@ -123,15 +123,12 @@ public class DefaultWebJarUrlProvider implements
WebJarUrlProvider {
return Optional.empty();
}
String normalized = StringUtils.stripStart(logicalPath, "/");
- if (normalized.contains("\\")) {
+ Optional<String> canonical =
StaticContentLoader.Validator.canonicalisePath(normalized);
+ if (canonical.isEmpty()) {
+ LOG.debug("Rejecting WebJar path that escapes above root: {}",
logicalPath);
return Optional.empty();
}
- for (String segment : normalized.split("/")) {
- if (segment.equals("..") || segment.equals(".")) {
- LOG.debug("Rejecting WebJar path with traversal segment: {}",
logicalPath);
- return Optional.empty();
- }
- }
+ normalized = canonical.get();
int slash = normalized.indexOf('/');
if (slash < 1 || slash == normalized.length() - 1) {
return Optional.empty();
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 db5caba14..d12164538 100644
---
a/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderTest.java
+++
b/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderTest.java
@@ -140,7 +140,24 @@ public class DefaultStaticContentLoaderTest extends
StrutsInternalTestCase {
expect(hostConfigMock.getInitParameter("loggerFactory")).andStubReturn(null);
defaultStaticContentLoader = new DefaultStaticContentLoader();
defaultStaticContentLoader.setHostConfig(hostConfigMock);
- 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 testFindStaticResourceRejectsBackslashPath() throws Exception {
+ responseMock.sendError(HttpServletResponse.SC_NOT_FOUND);
+ expectLastCall();
+ replay(responseMock);
+
defaultStaticContentLoader.findStaticResource("/static/..\\..\\secret",
requestMock, responseMock);
+ }
}
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 388aff42e..009721b08 100644
---
a/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java
+++
b/core/src/test/java/org/apache/struts2/dispatcher/DefaultStaticContentLoaderWebJarTest.java
@@ -68,7 +68,6 @@ public class DefaultStaticContentLoaderWebJarTest {
webJarLoader.setServeStaticContent("true");
webJarLoader.setStaticContentPath("/static");
webJarLoader.setServeStaticBrowserCache("true");
- webJarLoader.setEncoding("UTF-8");
org.apache.struts2.webjars.DefaultWebJarUrlProvider provider =
new org.apache.struts2.webjars.DefaultWebJarUrlProvider();
provider.setEnabled(String.valueOf(enabled));
@@ -116,4 +115,15 @@ public class DefaultStaticContentLoaderWebJarTest {
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);
+ }
}
diff --git
a/core/src/test/java/org/apache/struts2/dispatcher/StaticContentLoaderTest.java
b/core/src/test/java/org/apache/struts2/dispatcher/StaticContentLoaderTest.java
index fbf2a044a..a7c41c8a6 100644
---
a/core/src/test/java/org/apache/struts2/dispatcher/StaticContentLoaderTest.java
+++
b/core/src/test/java/org/apache/struts2/dispatcher/StaticContentLoaderTest.java
@@ -106,8 +106,6 @@ public class StaticContentLoaderTest extends TestCase {
hostConfigMock.expectAndReturn("getInitParameter",
C.args(C.eq("packages")), null);
hostConfigMock.expectAndReturn("getInitParameter",
C.args(C.eq("loggerFactory")), null);
- contentLoader.setEncoding("utf-8");
-
contentLoader.setHostConfig((HostConfig) hostConfigMock.proxy());
}