This is an automated email from the ASF dual-hosted git repository.

joerghoh pushed a commit to branch SLING-13371
in repository 
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-engine.git

commit 02aefc77ab4c1e70736d848a0ae6fa50da981c51
Author: Joerg Hoh <[email protected]>
AuthorDate: Tue Sep 29 23:30:16 2026 +0200

    SLING-13371 Fix path resolution divergence for ';' path parameters
---
 .../sling/engine/impl/request/RequestData.java     |  60 +++++-
 .../engine/impl/request/InitResourceTest.java      | 232 +++++++++++++++++++--
 .../impl/request/StripPathParametersTest.java      |  79 +++++++
 3 files changed, 342 insertions(+), 29 deletions(-)

diff --git 
a/src/main/java/org/apache/sling/engine/impl/request/RequestData.java 
b/src/main/java/org/apache/sling/engine/impl/request/RequestData.java
index ab64350..8fae95b 100644
--- a/src/main/java/org/apache/sling/engine/impl/request/RequestData.java
+++ b/src/main/java/org/apache/sling/engine/impl/request/RequestData.java
@@ -21,8 +21,8 @@ package org.apache.sling.engine.impl.request;
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
-import java.net.MalformedURLException;
-import java.net.URL;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.Set;
@@ -218,13 +218,31 @@ public class RequestData {
 
         StringBuffer requestURL = servletRequest.getRequestURL();
         String path = request.getPathInfo();
-        if (requestURL.indexOf(";") > -1 && !path.contains(";")) {
+        if (requestURL.indexOf(";") > -1 && path != null && 
!path.contains(";")) {
+            // The container stripped path parameters (';...') from the path 
info. Re-derive
+            // the path from the raw request URL to preserve them, but only 
use the re-derived
+            // path if it is equivalent to the container provided path info 
modulo the path
+            // parameters. Otherwise every upstream path based decision 
(container
+            // normalization, authentication requirements, filter patterns, 
access rules)
+            // would have been made on a different path than the one resolved 
here.
             try {
-                final URL rUrl = new URL(requestURL.toString());
+                // java.net.URI#getPath decodes percent escapes, so the 
derived path is in
+                // the same canonical (decoded) form as the container provided 
path info
+                final String rawPath = new 
URI(requestURL.toString()).getPath();
                 final String prefix = 
request.getContextPath().concat(request.getServletPath());
-                path = rUrl.getPath().substring(prefix.length());
-            } catch (final MalformedURLException e) {
-                // ignore
+                if (rawPath != null && rawPath.startsWith(prefix)) {
+                    final String candidate = 
rawPath.substring(prefix.length());
+                    if (path.equals(stripPathParameters(candidate))) {
+                        path = candidate;
+                    } else {
+                        log.debug(
+                                "initResource: ignoring path with parameters 
{} not equivalent to container provided path {}",
+                                candidate,
+                                path);
+                    }
+                }
+            } catch (final URISyntaxException e) {
+                // ignore and use the container provided path info
             }
         }
 
@@ -240,6 +258,34 @@ public class RequestData {
         return resource;
     }
 
+    /**
+     * Removes URL path parameters ({@code ;name=value}, scoped to a path 
segment as per
+     * RFC 3986) from the given path. Used to verify that a path re-derived 
from the raw
+     * request URL only differs from the container provided path info by the 
path
+     * parameters it preserves.
+     *
+     * @param path the path to strip path parameters from
+     * @return the path without path parameters
+     */
+    static String stripPathParameters(final String path) {
+        if (path.indexOf(';') < 0) {
+            return path;
+        }
+        final StringBuilder builder = new StringBuilder(path.length());
+        for (int i = 0; i < path.length(); i++) {
+            final char c = path.charAt(i);
+            if (c == ';') {
+                // skip the path parameter(s) up to the end of the current 
segment
+                while (i + 1 < path.length() && path.charAt(i + 1) != '/') {
+                    i++;
+                }
+            } else {
+                builder.append(c);
+            }
+        }
+        return builder.toString();
+    }
+
     public void initServlet(final Resource resource, final ServletResolver sr) 
{
         // the resource and the request path info, will never be null
         RequestPathInfo requestPathInfo = new SlingRequestPathInfo(resource);
diff --git 
a/src/test/java/org/apache/sling/engine/impl/request/InitResourceTest.java 
b/src/test/java/org/apache/sling/engine/impl/request/InitResourceTest.java
index 795fa5b..86f0acb 100644
--- a/src/test/java/org/apache/sling/engine/impl/request/InitResourceTest.java
+++ b/src/test/java/org/apache/sling/engine/impl/request/InitResourceTest.java
@@ -36,6 +36,31 @@ import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
 import org.junit.runners.Parameterized.Parameters;
 
+/**
+ * Verifies {@link RequestData#initResource(ResourceResolver)}, in particular 
the fallback
+ * that re-derives the resolution path from the raw request URL when the 
container strips
+ * URL path parameters ({@code ;name=value}) from {@link 
HttpServletRequest#getPathInfo()}.
+ *
+ * <p>Each row below documents, in this order:
+ * <ul>
+ *   <li><b>rawRequestURL</b> - the untouched URL as it appears on the wire 
(what
+ *       {@code getRequestURL()} returns) - i.e. the 
attacker/client-controlled input;</li>
+ *   <li><b>containerPathInfo</b> - what the servlet container hands back from
+ *       {@code getPathInfo()} after its own decoding/normalization (path 
parameters already
+ *       stripped);</li>
+ *   <li><b>expectedResolvedPath</b> - the path {@code initResource()} must 
hand to the
+ *       {@code ResourceResolver}, i.e. what the raw URL is effectively 
"decoded to" once path
+ *       parameters are safely re-attached (or not, if re-attaching them would 
be unsafe).</li>
+ * </ul>
+ *
+ * <p>Note: {@code servletPath} is the empty string in every row below except 
the
+ * null-path-info row - not because it was not worth testing, but because
+ * {@code SlingJakartaHttpServletRequestImpl.getServletPath()} always returns 
{@code ""} (Sling
+ * registers with the HTTP Whiteboard at servlet path {@code "/*"}), so the 
container's servlet
+ * path is unreachable at this call site regardless of what the underlying
+ * {@code HttpServletRequest} mock reports; only {@code contextPath} 
contributes to the prefix
+ * that gets stripped in practice.
+ */
 @RunWith(Parameterized.class)
 public class InitResourceTest {
 
@@ -45,23 +70,78 @@ public class InitResourceTest {
     private HttpServletResponse resp;
     private ResourceResolver resourceResolver;
 
-    private final String requestURL;
-    private final String pathInfo;
-    private final String expectedResolvePath;
-    private final String servletPath;
+    private final String description;
+    private final String rawRequestURL;
+    private final String containerPathInfo;
+    private final String expectedResolvedPath;
     private final String contextPath;
+    private final String servletPath;
 
-    @Parameters(name = "URL={0} path={1}")
+    @Parameters(name = "{index}: {0}")
     public static Collection<Object[]> data() {
         return Arrays.asList(new Object[][] {
-            {"http://localhost/one;v=1.1";, "/one;v=1.1", "/one;v=1.1", "", ""},
-            {"http://localhost/two;v=1.1";, "/two", "/two;v=1.1", "", ""},
-            {"http://localhost/three";, "/three", "/three", "", ""},
-            {"http://localhost/four%3Bv=1.1";, "/four", "/four", "", ""},
-            {"http://localhost/five%3Bv=1.1";, "/five;v=1.1", "/five;v=1.1", 
"", ""},
-            {"http://localhost/six;v=1.1";, "/six;v=1.1", "/six;v=1.1", "", ""},
-            {"http://localhost/seven";, "/seven;v=1.1", "/seven;v=1.1", "", ""},
             {
+                "container preserves a single path parameter as-is: no 
re-derivation needed",
+                "http://localhost/one;v=1.1";,
+                "/one;v=1.1",
+                "/one;v=1.1",
+                "",
+                ""
+            },
+            {
+                "container strips a single path parameter: re-derived and 
re-attached",
+                "http://localhost/two;v=1.1";,
+                "/two",
+                "/two;v=1.1",
+                "",
+                ""
+            },
+            {
+                "no path parameter present at all: path passed through 
unchanged",
+                "http://localhost/three";,
+                "/three",
+                "/three",
+                "",
+                ""
+            },
+            {
+                "raw URL has a percent-encoded semicolon (not a real path 
parameter): container "
+                        + "already decoded and dropped it, no re-derivation is 
attempted",
+                "http://localhost/four%3Bv=1.1";,
+                "/four",
+                "/four",
+                "",
+                ""
+            },
+            {
+                "raw URL has a percent-encoded semicolon that the container 
decoded and kept: "
+                        + "path passed through unchanged (already contains 
';')",
+                "http://localhost/five%3Bv=1.1";,
+                "/five;v=1.1",
+                "/five;v=1.1",
+                "",
+                ""
+            },
+            {
+                "container already exposes the literal ';' in path info: path 
passed through unchanged",
+                "http://localhost/six;v=1.1";,
+                "/six;v=1.1",
+                "/six;v=1.1",
+                "",
+                ""
+            },
+            {
+                "path parameter present only in container-provided path info 
(no ';' in raw URL): "
+                        + "path passed through unchanged",
+                "http://localhost/seven";,
+                "/seven;v=1.1",
+                "/seven;v=1.1",
+                "",
+                ""
+            },
+            {
+                "multiple path parameters across multiple segments, behind a 
context path: all "
+                        + "re-attached at their original segment",
                 "http://localhost/context/path;v=1.1/more/foo;x=y/end";,
                 "/path/more/foo/end",
                 "/path;v=1.1/more/foo;x=y/end",
@@ -69,20 +149,123 @@ public class InitResourceTest {
                 ""
             },
             {
+                "multiple path parameters across multiple segments, no context 
path: all "
+                        + "re-attached at their original segment",
                 
"http://localhost:4502/content;foo=bar/we-retail;bar=baz/us/en.html";,
                 "/content/we-retail/us/en.html",
                 "/content;foo=bar/we-retail;bar=baz/us/en.html",
                 "",
                 ""
-            }
+            },
+            {
+                "raw URL has a percent-encoded space: URI decoding brings the 
re-derived path "
+                        + "back to the same canonical (decoded) form as the 
container path info, "
+                        + "'%20' -> ' ', so it can be safely compared and 
re-attached",
+                "http://localhost/a%20b;v=1.1";,
+                "/a b",
+                "/a b;v=1.1",
+                "",
+                ""
+            },
+            {
+                "re-derived path (params stripped) does not match container 
path info at all: "
+                        + "the raw URL is rejected outright, container path 
info wins",
+                "http://localhost/other;x=1";,
+                "/two",
+                "/two",
+                "",
+                ""
+            },
+            {
+                "raw URL contains a literal '..;x' traversal segment the 
container normalized "
+                        + "away: rejected because it is not equivalent to the 
container path info "
+                        + "modulo path parameters, so it never resurfaces as a 
literal segment",
+                "http://localhost/private/..;x/secret";,
+                "/secret",
+                "/secret",
+                "",
+                ""
+            },
+            {
+                "raw URL contains a percent-encoded '..;x' traversal segment 
('%2e%2e' -> '..'): "
+                        + "same rejection as the literal '..;x' case above, 
decoding does not "
+                        + "change the outcome",
+                "http://localhost/private/%2e%2e;x/secret";,
+                "/secret",
+                "/secret",
+                "",
+                ""
+            },
+            {
+                "raw URL path does not even start with the context path 
prefix: fall back to "
+                        + "container path info instead of an out-of-bounds 
substring (previously a 500)",
+                "http://localhost/ctx;x/one";,
+                "/one",
+                "/one",
+                "/context",
+                ""
+            },
+            {
+                "raw URL shorter than the context+servlet path prefix: fall 
back to container "
+                        + "path info instead of an out-of-bounds substring 
(previously a 500)",
+                "http://localhost/a;b";,
+                "/x",
+                "/x",
+                "/context",
+                ""
+            },
+            {
+                "raw URL is not a syntactically valid URI (unencoded space): 
URISyntaxException "
+                        + "is swallowed, falls back to container path info",
+                "http://localhost/a b;v=1.1",
+                "/a b",
+                "/a b",
+                "",
+                ""
+            },
+            {
+                "container's own getServletPath()/getPathInfo() both return 
null (buggy/unusual "
+                        + "container): the Sling request wrapper then also 
exposes pathInfo=null; "
+                        + "must not NPE, path stays null (this is the only row 
using "
+                        + "servletPath=null instead of \"\")",
+                "http://localhost/context;x=1";,
+                null,
+                null,
+                "/context",
+                null
+            },
+            {
+                "path parameter sits immediately after the context+servlet 
prefix, leaving an "
+                        + "empty base path once the prefix is stripped: still 
re-attached correctly",
+                "http://localhost/context;x=1";,
+                "",
+                ";x=1",
+                "/context",
+                ""
+            },
+            {
+                "raw URL differs from container path info only in case ('/One' 
vs '/one'), not "
+                        + "just by path parameters: rejected as a near-miss, 
container path info wins",
+                "http://localhost/one;x=1";,
+                "/One",
+                "/One",
+                "",
+                ""
+            },
         });
     }
 
     public InitResourceTest(
-            String requestURL, String pathInfo, String expectedResolvePath, 
String contextPath, String servletPath) {
-        this.requestURL = requestURL;
-        this.pathInfo = pathInfo;
-        this.expectedResolvePath = expectedResolvePath;
+            String description,
+            String rawRequestURL,
+            String containerPathInfo,
+            String expectedResolvedPath,
+            String contextPath,
+            String servletPath) {
+        this.description = description;
+        this.rawRequestURL = rawRequestURL;
+        this.containerPathInfo = containerPathInfo;
+        this.expectedResolvedPath = expectedResolvedPath;
         this.contextPath = contextPath;
         this.servletPath = servletPath;
     }
@@ -103,12 +286,12 @@ public class InitResourceTest {
         context.checking(new Expectations() {
             {
                 allowing(req).getRequestURL();
-                will(returnValue(new StringBuffer(requestURL)));
+                will(returnValue(new StringBuffer(rawRequestURL)));
 
                 allowing(req).getRequestURI();
 
                 allowing(req).getPathInfo();
-                will(returnValue(pathInfo));
+                will(returnValue(containerPathInfo));
 
                 allowing(req).getContextPath();
                 will(returnValue(contextPath));
@@ -127,9 +310,14 @@ public class InitResourceTest {
                 
allowing(req).getAttribute(RequestProgressTracker.class.getName());
                 will(returnValue(null));
 
-                // Verify that the ResourceResolver is called with the 
expected path
-                allowing(resourceResolver)
-                        .resolve(with(any(HttpServletRequest.class)), 
with(equal(expectedResolvePath)));
+                // Verify that the ResourceResolver is called with the 
expected (re-derived or
+                // passed-through) path
+                if (expectedResolvedPath == null) {
+                    
allowing(resourceResolver).resolve(with(any(HttpServletRequest.class)), 
with(aNull(String.class)));
+                } else {
+                    allowing(resourceResolver)
+                            .resolve(with(any(HttpServletRequest.class)), 
with(equal(expectedResolvedPath)));
+                }
 
                 allowing(processor).getMaxCallCounter();
                 will(returnValue(2));
diff --git 
a/src/test/java/org/apache/sling/engine/impl/request/StripPathParametersTest.java
 
b/src/test/java/org/apache/sling/engine/impl/request/StripPathParametersTest.java
new file mode 100644
index 0000000..747bff9
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/engine/impl/request/StripPathParametersTest.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.engine.impl.request;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Direct unit tests for {@link RequestData#stripPathParameters(String)}, the 
helper used by
+ * {@link RequestData#initResource} to check whether a path re-derived from 
the raw request URL
+ * is equivalent - modulo URL path parameters ({@code ;name=value}) - to the 
container-provided
+ * path info.
+ */
+public class StripPathParametersTest {
+
+    @Test
+    public void noPathParameterIsUnchanged() {
+        assertEquals("/one/two", RequestData.stripPathParameters("/one/two"));
+    }
+
+    @Test
+    public void singlePathParameterIsRemoved() {
+        assertEquals("/one", RequestData.stripPathParameters("/one;v=1.1"));
+    }
+
+    @Test
+    public void pathParameterInMiddleSegmentIsRemoved() {
+        assertEquals("/one/two", 
RequestData.stripPathParameters("/one;v=1.1/two"));
+    }
+
+    @Test
+    public void multiplePathParametersInSameSegmentAreAllRemoved() {
+        assertEquals("/one/two", 
RequestData.stripPathParameters("/one;a=1;b=2/two"));
+    }
+
+    @Test
+    public void trailingPathParameterWithNoFollowingSegmentIsRemoved() {
+        assertEquals("/one", RequestData.stripPathParameters("/one;v=1.1"));
+        // no characters at all after the trailing ';'
+        assertEquals("/one", RequestData.stripPathParameters("/one;"));
+    }
+
+    @Test
+    public void emptyPathParameterValueIsRemoved() {
+        assertEquals("/one/two", 
RequestData.stripPathParameters("/one;=/two"));
+    }
+
+    @Test
+    public void leadingPathParameterOnRootIsRemoved() {
+        assertEquals("/one", RequestData.stripPathParameters(";x=1/one"));
+    }
+
+    @Test
+    public void pathParameterConsumingWholeInputYieldsEmptyString() {
+        assertEquals("", RequestData.stripPathParameters(";x=1"));
+    }
+
+    @Test
+    public void emptyStringIsUnchanged() {
+        assertEquals("", RequestData.stripPathParameters(""));
+    }
+}

Reply via email to