From bea48b54e2f423693e1e472129a86b030baf9eee Mon Sep 17 00:00:00 2001 From: Volkan Yazici Date: Thu, 19 Feb 2026 09:44:00 +0000 Subject: [PATCH] 8272758: Improve HttpServer to avoid partial file name matches while mapping request path to context path Reviewed-by: dfuchs --- .../com/sun/net/httpserver/HttpServer.java | 44 +++- .../share/classes/module-info.java | 32 ++- .../sun/net/httpserver/ContextList.java | 197 +++++++++++++++++- .../ContextPathMatcherPathPrefixTest.java | 154 ++++++++++++++ .../ContextPathMatcherStringPrefixTest.java | 64 ++++++ .../httpclient/PlainProxyConnectionTest.java | 6 +- 6 files changed, 471 insertions(+), 26 deletions(-) create mode 100644 test/jdk/com/sun/net/httpserver/ContextPathMatcherPathPrefixTest.java create mode 100644 test/jdk/com/sun/net/httpserver/ContextPathMatcherStringPrefixTest.java diff --git a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpServer.java b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpServer.java index a0271fed146..fb10ecb0755 100644 --- a/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpServer.java +++ b/src/jdk.httpserver/share/classes/com/sun/net/httpserver/HttpServer.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2005, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2005, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -75,7 +75,7 @@ import java.util.concurrent.Executor; * *

The following table shows some request URIs and which, if any context they would * match with: - * + *
description
* * * @@ -278,10 +278,20 @@ public abstract class HttpServer { *

The class overview describes how incoming request URIs are * mapped to HttpContext instances. * - * @apiNote The path should generally, but is not required to, end with '/'. - * If the path does not end with '/', eg such as with {@code "/foo"} then - * this would match requests with a path of {@code "/foobar"} or - * {@code "/foo/bar"}. + * @apiNote + * The path should generally, but is not required to, end with {@code /}. + * If the path does not end with {@code /}, e.g., such as with {@code /foo}, + * then some implementations may use string prefix matching where + * this context path matches request paths {@code /foo}, + * {@code /foo/bar}, or {@code /foobar}. Others may use path prefix + * matching where {@code /foo} matches request paths {@code /foo} and + * {@code /foo/bar}, but not {@code /foobar}. + * + * @implNote + * By default, the JDK built-in implementation uses path prefix matching. + * String prefix matching can be enabled using the + * {@link jdk.httpserver/##sun.net.httpserver.pathMatcher sun.net.httpserver.pathMatcher} + * system property. * * @param path the root URI path to associate the context with * @param handler the handler to invoke for incoming requests @@ -289,6 +299,8 @@ public abstract class HttpServer { * already exists for this path * @throws NullPointerException if either path, or handler are {@code null} * @return an instance of {@code HttpContext} + * + * @see jdk.httpserver/##sun.net.httpserver.pathMatcher sun.net.httpserver.pathMatcher */ public abstract HttpContext createContext(String path, HttpHandler handler); @@ -308,16 +320,28 @@ public abstract class HttpServer { *

The class overview describes how incoming request URIs are * mapped to {@code HttpContext} instances. * - * @apiNote The path should generally, but is not required to, end with '/'. - * If the path does not end with '/', eg such as with {@code "/foo"} then - * this would match requests with a path of {@code "/foobar"} or - * {@code "/foo/bar"}. + * @apiNote + * The path should generally, but is not required to, end with {@code /}. + * If the path does not end with {@code /}, e.g., such as with {@code /foo}, + * then some implementations may use string prefix matching where + * this context path matches request paths {@code /foo}, + * {@code /foo/bar}, or {@code /foobar}. Others may use path prefix + * matching where {@code /foo} matches request paths + * {@code /foo} and {@code /foo/bar}, but not {@code /foobar}. + * + * @implNote + * By default, the JDK built-in implementation uses path prefix matching. + * String prefix matching can be enabled using the + * {@link jdk.httpserver/##sun.net.httpserver.pathMatcher sun.net.httpserver.pathMatcher} + * system property. * * @param path the root URI path to associate the context with * @throws IllegalArgumentException if path is invalid, or if a context * already exists for this path * @throws NullPointerException if path is {@code null} * @return an instance of {@code HttpContext} + * + * @see jdk.httpserver/##sun.net.httpserver.pathMatcher sun.net.httpserver.pathMatcher */ public abstract HttpContext createContext(String path); diff --git a/src/jdk.httpserver/share/classes/module-info.java b/src/jdk.httpserver/share/classes/module-info.java index ac147582b14..0a0e77c628f 100644 --- a/src/jdk.httpserver/share/classes/module-info.java +++ b/src/jdk.httpserver/share/classes/module-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2014, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -101,7 +101,35 @@ import com.sun.net.httpserver.*; *

  • {@systemProperty sun.net.httpserver.nodelay} (default: false)
    * Boolean value, which if true, sets the {@link java.net.StandardSocketOptions#TCP_NODELAY TCP_NODELAY} * socket option on all incoming connections. - *

  • + * + *
  • + *

    {@systemProperty sun.net.httpserver.pathMatcher} (default: + * {@code pathPrefix})
    + * + * The path matching scheme used to route requests to context handlers. + * The property can be configured with one of the following values:

    + * + *
    + *
    + *
    {@code pathPrefix} (default)
    + *
    The request path must begin with the context path and all matching path + * segments must be identical. For instance, the context path {@code /foo} + * would match request paths {@code /foo}, {@code /foo/}, and {@code /foo/bar}, + * but not {@code /foobar}.
    + *
    {@code stringPrefix}
    + *
    The request path string must begin with the context path string. For + * instance, the context path {@code /foo} would match request paths + * {@code /foo}, {@code /foo/}, {@code /foo/bar}, and {@code /foobar}. + *
    + *
    + *
    + * + *

    In case of a blank or invalid value, the default will be used.

    + * + *

    This property and the ability to restore the string prefix matching + * behavior may be removed in a future release.

    + *
  • + * * * @apiNote The API and SPI in this module are designed and implemented to support a minimal * HTTP server and simple HTTP semantics primarily. diff --git a/src/jdk.httpserver/share/classes/sun/net/httpserver/ContextList.java b/src/jdk.httpserver/share/classes/sun/net/httpserver/ContextList.java index 96b55575928..6393ca34798 100644 --- a/src/jdk.httpserver/share/classes/sun/net/httpserver/ContextList.java +++ b/src/jdk.httpserver/share/classes/sun/net/httpserver/ContextList.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2005, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2005, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -26,13 +26,22 @@ package sun.net.httpserver; import java.util.*; +import java.util.function.BiPredicate; class ContextList { + private static final System.Logger LOGGER = System.getLogger(ContextList.class.getName()); + private final LinkedList list = new LinkedList<>(); public synchronized void add(HttpContextImpl ctx) { + assert ctx != null; + // `findContext(String protocol, String path, ContextPathMatcher matcher)` + // expects the protocol to be lower-cased using ROOT locale, hence: + assert ctx.getProtocol().equals(ctx.getProtocol().toLowerCase(Locale.ROOT)); assert ctx.getPath() != null; + // `ContextPathMatcher` expects context paths to be non-empty: + assert !ctx.getPath().isEmpty(); if (contains(ctx)) { throw new IllegalArgumentException("cannot add context to list"); } @@ -40,21 +49,25 @@ class ContextList { } boolean contains(HttpContextImpl ctx) { - return findContext(ctx.getProtocol(), ctx.getPath(), true) != null; + return findContext(ctx.getProtocol(), ctx.getPath(), ContextPathMatcher.EXACT) != null; } public synchronized int size() { return list.size(); } - /* initially contexts are located only by protocol:path. - * Context with longest prefix matches (currently case-sensitive) + /** + * {@return the context with the longest case-sensitive prefix match} + * + * @param protocol the request protocol + * @param path the request path */ - synchronized HttpContextImpl findContext(String protocol, String path) { - return findContext(protocol, path, false); + HttpContextImpl findContext(String protocol, String path) { + var matcher = ContextPathMatcher.ofConfiguredPrefixPathMatcher(); + return findContext(protocol, path, matcher); } - synchronized HttpContextImpl findContext(String protocol, String path, boolean exact) { + private synchronized HttpContextImpl findContext(String protocol, String path, ContextPathMatcher matcher) { protocol = protocol.toLowerCase(Locale.ROOT); String longest = ""; HttpContextImpl lc = null; @@ -63,9 +76,7 @@ class ContextList { continue; } String cpath = ctx.getPath(); - if (exact && !cpath.equals(path)) { - continue; - } else if (!exact && !path.startsWith(cpath)) { + if (!matcher.test(cpath, path)) { continue; } if (cpath.length() > longest.length()) { @@ -76,10 +87,174 @@ class ContextList { return lc; } + private enum ContextPathMatcher implements BiPredicate { + + /** + * Tests if both the request path and the context path are identical. + */ + EXACT(String::equals), + + /** + * Tests string prefix matches where the request path string + * starts with the context path string. + * + *

    Examples

    + * + *
    description
    Request URI
    + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
    Context pathRequest path
    /foo/foo//foo/bar/foobar
    /YYYY
    /fooYYYY
    /foo/NYYN
    + */ + STRING_PREFIX((contextPath, requestPath) -> requestPath.startsWith(contextPath)), + + /** + * Tests path prefix matches where path segments must have an + * exact match. + * + *

    Examples

    + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
    Context pathRequest path
    /foo/foo//foo/bar/foobar
    /YYYY
    /fooYYYN
    /foo/NYYN
    + */ + PATH_PREFIX((contextPath, requestPath) -> { + + // Fast-path for `/` + if ("/".equals(contextPath)) { + return true; + } + + // Does the request path prefix match? + if (requestPath.startsWith(contextPath)) { + + // Is it an exact match? + int contextPathLength = contextPath.length(); + if (requestPath.length() == contextPathLength) { + return true; + } + + // Is it a path-prefix match? + assert contextPathLength > 0; + return + // Case 1: The request path starts with the context + // path, but the context path has an extra path + // separator suffix. For instance, the context path is + // `/foo/` and the request path is `/foo/bar`. + contextPath.charAt(contextPathLength - 1) == '/' || + // Case 2: The request path starts with the + // context path, but the request path has an + // extra path separator suffix. For instance, + // context path is `/foo` and the request path + // is `/foo/` or `/foo/bar`. + requestPath.charAt(contextPathLength) == '/'; + + } + + return false; + + }); + + private final BiPredicate predicate; + + ContextPathMatcher(BiPredicate predicate) { + this.predicate = predicate; + } + + @Override + public boolean test(String contextPath, String requestPath) { + return predicate.test(contextPath, requestPath); + } + + private static ContextPathMatcher ofConfiguredPrefixPathMatcher() { + var propertyName = "sun.net.httpserver.pathMatcher"; + var propertyValueDefault = "pathPrefix"; + var propertyValue = System.getProperty(propertyName, propertyValueDefault); + return switch (propertyValue) { + case "pathPrefix" -> ContextPathMatcher.PATH_PREFIX; + case "stringPrefix" -> ContextPathMatcher.STRING_PREFIX; + default -> { + LOGGER.log( + System.Logger.Level.WARNING, + "System property \"{}\" contains an invalid value: \"{}\". Falling back to the default: \"{}\"", + propertyName, propertyValue, propertyValueDefault); + yield ContextPathMatcher.PATH_PREFIX; + } + }; + } + + } + public synchronized void remove(String protocol, String path) throws IllegalArgumentException { - HttpContextImpl ctx = findContext(protocol, path, true); + HttpContextImpl ctx = findContext(protocol, path, ContextPathMatcher.EXACT); if (ctx == null) { throw new IllegalArgumentException("cannot remove element from list"); } diff --git a/test/jdk/com/sun/net/httpserver/ContextPathMatcherPathPrefixTest.java b/test/jdk/com/sun/net/httpserver/ContextPathMatcherPathPrefixTest.java new file mode 100644 index 00000000000..e1cff62a45f --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/ContextPathMatcherPathPrefixTest.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; + +import static java.net.http.HttpClient.Builder.NO_PROXY; + +import org.junit.jupiter.api.AfterAll; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/* + * @test id=default + * @bug 8272758 + * @summary Verifies path prefix matching using defaults + * @build EchoHandler + * @run junit ${test.main.class} + */ + +/* + * @test id=withProperty + * @bug 8272758 + * @summary Verifies path prefix matching by providing a system property + * @build EchoHandler + * @run junit/othervm + * -Dsun.net.httpserver.pathMatcher=pathPrefix + * ${test.main.class} + */ + +/* + * @test id=withInvalidProperty + * @bug 8272758 + * @summary Verifies path prefix matching by providing a system property + * containing an invalid value, and observing it fall back to the + * default + * @build EchoHandler + * @run junit/othervm + * -Dsun.net.httpserver.pathMatcher=noSuchMatcher + * ${test.main.class} + */ + +public class ContextPathMatcherPathPrefixTest { + + protected static final HttpClient CLIENT = + HttpClient.newBuilder().proxy(NO_PROXY).build(); + + @AfterAll + static void stopClient() { + CLIENT.shutdownNow(); + } + + @Test + void testContextPathOfEmptyString() { + var iae = assertThrows(IllegalArgumentException.class, () -> new Infra("")); + assertEquals("Illegal value for path or protocol", iae.getMessage()); + } + + @Test + void testContextPathAtRoot() throws Exception { + try (var infra = new Infra("/")) { + infra.expect(200, "/foo", "/foo/", "/foo/bar", "/foobar"); + } + } + + @Test + void testContextPathAtSubDir() throws Exception { + try (var infra = new Infra("/foo")) { + infra.expect(200, "/foo", "/foo/", "/foo/bar"); + infra.expect(404, "/foobar"); + } + } + + @Test + void testContextPathAtSubDirWithTrailingSlash() throws Exception { + try (var infra = new Infra("/foo/")) { + infra.expect(200, "/foo/", "/foo/bar"); + infra.expect(404, "/foo", "/foobar"); + } + } + + protected static final class Infra implements AutoCloseable { + + private static final InetSocketAddress LO_SA_0 = + new InetSocketAddress(InetAddress.getLoopbackAddress(), 0); + + private static final HttpHandler HANDLER = new EchoHandler(); + + private final HttpServer server; + + private final String contextPath; + + protected Infra(String contextPath) throws IOException { + this.server = HttpServer.create(LO_SA_0, 10); + server.createContext(contextPath, HANDLER); + server.start(); + this.contextPath = contextPath; + } + + protected void expect(int statusCode, String... requestPaths) throws Exception { + for (String requestPath : requestPaths) { + var requestURI = URI.create("http://%s:%s%s".formatted( + server.getAddress().getHostString(), + server.getAddress().getPort(), + requestPath)); + var request = HttpRequest.newBuilder(requestURI).build(); + var response = CLIENT.send(request, HttpResponse.BodyHandlers.discarding()); + assertEquals( + statusCode, response.statusCode(), + "unexpected status code " + Map.of( + "contextPath", contextPath, + "requestPath", requestPath)); + } + } + + @Override + public void close() { + server.stop(0); + } + + } + +} diff --git a/test/jdk/com/sun/net/httpserver/ContextPathMatcherStringPrefixTest.java b/test/jdk/com/sun/net/httpserver/ContextPathMatcherStringPrefixTest.java new file mode 100644 index 00000000000..3f3008a8531 --- /dev/null +++ b/test/jdk/com/sun/net/httpserver/ContextPathMatcherStringPrefixTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +import org.junit.jupiter.api.Test; + +/* + * @test + * @bug 8272758 + * @summary Verifies string prefix matching configured using a system property + * @build ContextPathMatcherPathPrefixTest + * EchoHandler + * @run junit/othervm + * -Dsun.net.httpserver.pathMatcher=stringPrefix + * ${test.main.class} + */ + +class ContextPathMatcherStringPrefixTest extends ContextPathMatcherPathPrefixTest { + + @Test + @Override + void testContextPathAtRoot() throws Exception { + try (var infra = new Infra("/")) { + infra.expect(200, "/foo", "/foo/", "/foo/bar", "/foobar"); + } + } + + @Test + @Override + void testContextPathAtSubDir() throws Exception { + try (var infra = new Infra("/foo")) { + infra.expect(200, "/foo", "/foo/", "/foo/bar", "/foobar"); + } + } + + @Test + @Override + void testContextPathAtSubDirWithTrailingSlash() throws Exception { + try (var infra = new Infra("/foo/")) { + infra.expect(200, "/foo/", "/foo/bar"); + infra.expect(404, "/foo", "/foobar"); + } + } + +} diff --git a/test/jdk/java/net/httpclient/PlainProxyConnectionTest.java b/test/jdk/java/net/httpclient/PlainProxyConnectionTest.java index c2fab85f8ff..cd7049285f1 100644 --- a/test/jdk/java/net/httpclient/PlainProxyConnectionTest.java +++ b/test/jdk/java/net/httpclient/PlainProxyConnectionTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -208,9 +208,9 @@ public class PlainProxyConnectionTest { System.out.println("Server is: " + server.getAddress().toString()); URI uri = new URI("http", null, server.getAddress().getAddress().getHostAddress(), - server.getAddress().getPort(), PATH + "x", + server.getAddress().getPort(), PATH + "/x", null, null); - URI proxiedURI = new URI("http://some.host.that.does.not.exist:4242" + PATH + "x"); + URI proxiedURI = new URI("http://some.host.that.does.not.exist:4242" + PATH + "/x"); performSanityTest(server, uri, proxiedURI);