* | Request URI |
@@ -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
+ *
+ *
+ *
+ *
+ * | Context path |
+ * Request path |
+ *
+ *
+ * | /foo |
+ * /foo/ |
+ * /foo/bar |
+ * /foobar |
+ *
+ *
+ *
+ *
+ * | / |
+ * Y |
+ * Y |
+ * Y |
+ * Y |
+ *
+ *
+ * | /foo |
+ * Y |
+ * Y |
+ * Y |
+ * Y |
+ *
+ *
+ * | /foo/ |
+ * N |
+ * Y |
+ * Y |
+ * N |
+ *
+ *
+ *
+ */
+ STRING_PREFIX((contextPath, requestPath) -> requestPath.startsWith(contextPath)),
+
+ /**
+ * Tests path prefix matches where path segments must have an
+ * exact match.
+ *
+ * Examples
+ *
+ *
+ *
+ *
+ * | Context path |
+ * Request path |
+ *
+ *
+ * | /foo |
+ * /foo/ |
+ * /foo/bar |
+ * /foobar |
+ *
+ *
+ *
+ *
+ * | / |
+ * Y |
+ * Y |
+ * Y |
+ * Y |
+ *
+ *
+ * | /foo |
+ * Y |
+ * Y |
+ * Y |
+ * N |
+ *
+ *
+ * | /foo/ |
+ * N |
+ * Y |
+ * Y |
+ * N |
+ *
+ *
+ *
+ */
+ 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);