From 819e6b31a34813af1231cb02ad391e4e75f099a3 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 22 May 2026 13:37:00 +0200 Subject: [PATCH] fix: Enforce exact path match on extra routes JDK HttpServer.createContext does prefix matching, so /health and /alive were dispatched for /health232 or /alive/34. ExtraRouteAdapter now rejects any request whose path does not exactly equal the registered route. --- .../com/retailsvc/http/OpenApiServer.java | 2 +- .../http/internal/ExtraRouteAdapter.java | 9 +- .../retailsvc/http/ExactUrlMatchingIT.java | 100 ++++++++++++++++++ .../http/internal/ExtraRouteAdapterTest.java | 2 +- 4 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/retailsvc/http/ExactUrlMatchingIT.java diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 8dbad78b..ec54134b 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -137,7 +137,7 @@ record HandlerConfig( for (Map.Entry e : handlerConfig.extras().entrySet()) { HttpContext extraCtx = httpServer.createContext(e.getKey()); extraCtx.getFilters().add(new ExceptionFilter(exceptionHandler, renderer)); - extraCtx.setHandler(new ExtraRouteAdapter(e.getValue(), renderer)); + extraCtx.setHandler(new ExtraRouteAdapter(e.getKey(), e.getValue(), renderer)); } if (!"/".equals(basePath)) { diff --git a/src/main/java/com/retailsvc/http/internal/ExtraRouteAdapter.java b/src/main/java/com/retailsvc/http/internal/ExtraRouteAdapter.java index 95d496d7..901b60aa 100644 --- a/src/main/java/com/retailsvc/http/internal/ExtraRouteAdapter.java +++ b/src/main/java/com/retailsvc/http/internal/ExtraRouteAdapter.java @@ -1,5 +1,6 @@ package com.retailsvc.http.internal; +import com.retailsvc.http.NotFoundException; import com.retailsvc.http.Request; import com.retailsvc.http.RequestHandler; import com.retailsvc.http.Response; @@ -19,16 +20,22 @@ */ public final class ExtraRouteAdapter implements HttpHandler { + private final String path; private final RequestHandler handler; private final ResponseRenderer renderer; - public ExtraRouteAdapter(RequestHandler handler, ResponseRenderer renderer) { + public ExtraRouteAdapter(String path, RequestHandler handler, ResponseRenderer renderer) { + this.path = path; this.handler = handler; this.renderer = renderer; } @Override public void handle(HttpExchange exchange) throws IOException { + String requested = exchange.getRequestURI().getPath(); + if (!path.equals(requested)) { + throw new NotFoundException(exchange.getRequestMethod() + " " + requested); + } byte[] body = exchange.getRequestBody().readAllBytes(); HttpMethod method = HttpMethod.parse(exchange.getRequestMethod()); var headers = exchange.getRequestHeaders(); diff --git a/src/test/java/com/retailsvc/http/ExactUrlMatchingIT.java b/src/test/java/com/retailsvc/http/ExactUrlMatchingIT.java new file mode 100644 index 00000000..67c51b1b --- /dev/null +++ b/src/test/java/com/retailsvc/http/ExactUrlMatchingIT.java @@ -0,0 +1,100 @@ +package com.retailsvc.http; + +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_OK; +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ExactUrlMatchingIT extends ServerBaseTest { + + @Test + void extraRouteRejectsTrailingSuffix() throws Exception { + try (var s = + newBuilder() + .spec(spec) + .handlers(stubAllHandlers(Map.of())) + .port(0) + .extraRoute("/alive", Handlers.aliveHandler()) + .build(); + var client = httpClient()) { + + HttpResponse resp = get(client, s, "/alive232"); + + assertThat(resp.statusCode()).isEqualTo(HTTP_NOT_FOUND); + } + } + + @Test + void extraRouteRejectsSubPath() throws Exception { + try (var s = + newBuilder() + .spec(spec) + .handlers(stubAllHandlers(Map.of())) + .port(0) + .extraRoute("/alive", Handlers.aliveHandler()) + .build(); + var client = httpClient()) { + + HttpResponse resp = get(client, s, "/alive/34"); + + assertThat(resp.statusCode()).isEqualTo(HTTP_NOT_FOUND); + } + } + + @Test + void extraRouteAcceptsExactPath() throws Exception { + try (var s = + newBuilder() + .spec(spec) + .handlers(stubAllHandlers(Map.of())) + .port(0) + .extraRoute("/alive", Handlers.aliveHandler()) + .build(); + var client = httpClient()) { + + HttpResponse resp = get(client, s, "/alive"); + + assertThat(resp.statusCode()).isEqualTo(204); + } + } + + @Test + void specRouteRejectsTrailingSuffix() throws Exception { + Map handlers = Map.of("get-data", req -> Response.status(HTTP_OK)); + try (var s = newBuilder().spec(spec).handlers(stubAllHandlers(handlers)).port(0).build(); + var client = httpClient()) { + + HttpResponse resp = get(client, s, "/api/v1/data232"); + + assertThat(resp.statusCode()).isEqualTo(HTTP_NOT_FOUND); + } + } + + @Test + void specRouteRejectsBasePathSuffix() throws Exception { + try (var s = newBuilder().spec(spec).handlers(stubAllHandlers(Map.of())).port(0).build(); + var client = httpClient()) { + + HttpResponse resp = get(client, s, "/api/v1xyz/data"); + + assertThat(resp.statusCode()).isEqualTo(HTTP_NOT_FOUND); + } + } + + private HttpResponse get(HttpClient client, OpenApiServer s, String path) + throws Exception { + var req = + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + s.listenPort() + path)) + .GET() + .build(); + return client.send(req, BodyHandlers.ofString()); + } +} diff --git a/src/test/java/com/retailsvc/http/internal/ExtraRouteAdapterTest.java b/src/test/java/com/retailsvc/http/internal/ExtraRouteAdapterTest.java index 184c5461..11a0e5eb 100644 --- a/src/test/java/com/retailsvc/http/internal/ExtraRouteAdapterTest.java +++ b/src/test/java/com/retailsvc/http/internal/ExtraRouteAdapterTest.java @@ -32,7 +32,7 @@ void buildsRequestWithMethodQueryHeadersAndBodyBytesAndNullOperationId() throws Map mappers = Map.of("application/json", new GsonTypeMapper()); ResponseRenderer renderer = new ResponseRenderer(mappers); - ExtraRouteAdapter adapter = new ExtraRouteAdapter(handler, renderer); + ExtraRouteAdapter adapter = new ExtraRouteAdapter("/alive", handler, renderer); HttpExchange ex = mock(HttpExchange.class); Headers reqHeaders = new Headers();