diff --git a/java/src/org/openqa/selenium/grid/commands/Hub.java b/java/src/org/openqa/selenium/grid/commands/Hub.java index 86733f0524fa9..864978c55b07a 100644 --- a/java/src/org/openqa/selenium/grid/commands/Hub.java +++ b/java/src/org/openqa/selenium/grid/commands/Hub.java @@ -31,6 +31,7 @@ import java.net.MalformedURLException; import java.net.URL; import java.util.Collections; +import java.util.List; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; @@ -48,6 +49,8 @@ import org.openqa.selenium.grid.log.LoggingOptions; import org.openqa.selenium.grid.router.ProxyWebsocketsIntoGrid; import org.openqa.selenium.grid.router.Router; +import org.openqa.selenium.grid.router.httpd.BlockedRoute; +import org.openqa.selenium.grid.router.httpd.BlockedRoutesFilter; import org.openqa.selenium.grid.router.httpd.RouterOptions; import org.openqa.selenium.grid.security.BasicAuthenticationFilter; import org.openqa.selenium.grid.security.Secret; @@ -207,6 +210,13 @@ protected Handlers createHandlers(Config config) { httpHandler = httpHandler.with(new BasicAuthenticationFilter(uap.username(), uap.password())); } + // Apply blocked routes filter + List blockedRoutes = routerOptions.getBlockedRoutes(); + if (!blockedRoutes.isEmpty()) { + LOG.info("Blocking " + blockedRoutes.size() + " route(s): " + blockedRoutes); + httpHandler = BlockedRoutesFilter.with(httpHandler, blockedRoutes); + } + // Allow the liveness endpoint to be reached, since k8s doesn't make it easy to authenticate // these checks httpHandler = combine(httpHandler, Route.get("/readyz").to(() -> readinessCheck)); diff --git a/java/src/org/openqa/selenium/grid/commands/Standalone.java b/java/src/org/openqa/selenium/grid/commands/Standalone.java index 83875cbb59f5f..994043b765405 100644 --- a/java/src/org/openqa/selenium/grid/commands/Standalone.java +++ b/java/src/org/openqa/selenium/grid/commands/Standalone.java @@ -32,6 +32,7 @@ import java.net.URI; import java.net.URL; import java.util.Collections; +import java.util.List; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; @@ -53,6 +54,8 @@ import org.openqa.selenium.grid.node.ProxyNodeWebsockets; import org.openqa.selenium.grid.node.config.NodeOptions; import org.openqa.selenium.grid.router.Router; +import org.openqa.selenium.grid.router.httpd.BlockedRoute; +import org.openqa.selenium.grid.router.httpd.BlockedRoutesFilter; import org.openqa.selenium.grid.router.httpd.RouterOptions; import org.openqa.selenium.grid.security.BasicAuthenticationFilter; import org.openqa.selenium.grid.security.Secret; @@ -213,6 +216,13 @@ protected Handlers createHandlers(Config config) { httpHandler = httpHandler.with(new BasicAuthenticationFilter(uap.username(), uap.password())); } + // Apply blocked routes filter + List blockedRoutes = routerOptions.getBlockedRoutes(); + if (!blockedRoutes.isEmpty()) { + LOG.info("Blocking " + blockedRoutes.size() + " route(s): " + blockedRoutes); + httpHandler = BlockedRoutesFilter.with(httpHandler, blockedRoutes); + } + // Allow the liveness endpoint to be reached, since k8s doesn't make it easy to authenticate // these checks httpHandler = combine(httpHandler, Route.get("/readyz").to(() -> readinessCheck)); diff --git a/java/src/org/openqa/selenium/grid/router/httpd/BlockedRoute.java b/java/src/org/openqa/selenium/grid/router/httpd/BlockedRoute.java new file mode 100644 index 0000000000000..2d74fea08deb8 --- /dev/null +++ b/java/src/org/openqa/selenium/grid/router/httpd/BlockedRoute.java @@ -0,0 +1,221 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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.openqa.selenium.grid.router.httpd; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** Represents a blocked route with HTTP method and path. */ +public class BlockedRoute { + private final String method; + private final String path; + + public BlockedRoute(String method, String path) { + this.method = method.toUpperCase(); + this.path = path; + } + + public String getMethod() { + return method; + } + + public String getPath() { + return path; + } + + /** + * Creates a BlockedRoute from a string in the format "METHOD:path". + * + * @param routeStr String representation of blocked route + * @return BlockedRoute instance + * @throws IllegalArgumentException if the format is invalid + */ + public static BlockedRoute fromString(String routeStr) { + if (routeStr == null || routeStr.trim().isEmpty()) { + throw new IllegalArgumentException("Route string cannot be null or empty"); + } + + String[] parts = routeStr.split(":", 2); + if (parts.length != 2) { + throw new IllegalArgumentException( + "Invalid route format. Expected 'METHOD:path', got: " + routeStr); + } + + String method = parts[0].trim().toUpperCase(); + String path = parts[1].trim(); + + if (method.isEmpty() || path.isEmpty()) { + throw new IllegalArgumentException("Method and path cannot be empty. Got: " + routeStr); + } + + return new BlockedRoute(method, path); + } + + /** + * Checks if the given HTTP method and request path match this blocked route. + * + * @param requestMethod HTTP method of the request + * @param requestPath Path of the request + * @return true if the route should be blocked + */ + public boolean matches(String requestMethod, String requestPath) { + if (!method.equals(requestMethod.toUpperCase())) { + return false; + } + + // Use safe string-based path matching instead of regex to prevent ReDoS attacks + return matchesPathPattern(path, requestPath); + } + + /** + * Safely matches a path pattern against a request path without using regex. Handles path + * parameters like {session-id} by treating them as wildcards. Both paths are normalized to + * prevent path traversal attacks. + * + * @param pattern The path pattern to match against + * @param requestPath The actual request path + * @return true if the paths match + */ + private boolean matchesPathPattern(String pattern, String requestPath) { + // Normalize both paths to prevent path traversal attacks + String normalizedPattern = normalizePath(pattern); + String normalizedRequestPath = normalizePath(requestPath); + + // Split both paths into segments + String[] patternSegments = normalizedPattern.split("/", -1); // keep trailing empty segments + String[] requestSegments = normalizedRequestPath.split("/", -1); + + // Paths must have the same number of segments + if (patternSegments.length != requestSegments.length) { + return false; + } + + // Compare each segment + for (int i = 0; i < patternSegments.length; i++) { + String patternSegment = patternSegments[i]; + String requestSegment = requestSegments[i]; + + // If both are empty (leading/trailing slash), continue + if (patternSegment.isEmpty() && requestSegment.isEmpty()) { + continue; + } + // If pattern segment is a path parameter (enclosed in {}), it matches any non-empty segment + if (isPathParameter(patternSegment)) { + if (requestSegment.isEmpty()) { + return false; + } + } else { + // For literal segments, they must match exactly + if (!patternSegment.equals(requestSegment)) { + return false; + } + } + } + + return true; + } + + /** + * Normalizes a path to prevent path traversal attacks. This method: 1. URL decodes + * percent-encoded characters 2. Normalizes multiple consecutive slashes to single slashes 3. + * Resolves path traversal sequences (../) 4. Ensures the path doesn't escape the root directory + * + * @param path The path to normalize + * @return The normalized path + * @throws IllegalArgumentException if the path contains invalid traversal sequences + */ + private String normalizePath(String path) { + if (path == null || path.isEmpty()) { + return "/"; + } + + try { + // URL decode the path to handle percent-encoded characters like %2F + String decodedPath = URLDecoder.decode(path, StandardCharsets.UTF_8); + + // Normalize multiple consecutive slashes to single slashes + String normalizedPath = decodedPath.replaceAll("/+", "/"); + + // Split into segments and resolve path traversal + String[] segments = normalizedPath.split("/"); + List resolvedSegments = new ArrayList<>(); + + for (String segment : segments) { + if (segment.isEmpty() || ".".equals(segment)) { + // Skip empty segments and current directory references + continue; + } else if ("..".equals(segment)) { + // Go up one directory level + if (!resolvedSegments.isEmpty()) { + resolvedSegments.remove(resolvedSegments.size() - 1); + } else { + // Attempting to go above root - this is a security violation + throw new IllegalArgumentException("Path traversal attack detected: " + path); + } + } else { + // Add normal segment + resolvedSegments.add(segment); + } + } + + // Reconstruct the path + StringBuilder result = new StringBuilder(); + for (String segment : resolvedSegments) { + result.append("/").append(segment); + } + + // Ensure the result starts with / and handle empty path case + String finalPath = result.toString(); + return finalPath.isEmpty() ? "/" : finalPath; + + } catch (Exception e) { + // If URL decoding fails or any other error occurs, throw security exception + throw new IllegalArgumentException("Invalid path format: " + path, e); + } + } + + /** + * Checks if a path segment is a path parameter (enclosed in curly braces). + * + * @param segment The path segment to check + * @return true if it's a path parameter + */ + private boolean isPathParameter(String segment) { + return segment.startsWith("{") && segment.endsWith("}") && segment.length() > 2; + } + + @Override + public String toString() { + return method + ":" + path; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + BlockedRoute that = (BlockedRoute) obj; + return method.equals(that.method) && path.equals(that.path); + } + + @Override + public int hashCode() { + return method.hashCode() * 31 + path.hashCode(); + } +} diff --git a/java/src/org/openqa/selenium/grid/router/httpd/BlockedRoutesFilter.java b/java/src/org/openqa/selenium/grid/router/httpd/BlockedRoutesFilter.java new file mode 100644 index 0000000000000..4a302e58d65d0 --- /dev/null +++ b/java/src/org/openqa/selenium/grid/router/httpd/BlockedRoutesFilter.java @@ -0,0 +1,86 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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.openqa.selenium.grid.router.httpd; + +import java.net.URI; +import java.util.List; +import java.util.logging.Logger; +import org.openqa.selenium.remote.http.Contents; +import org.openqa.selenium.remote.http.HttpHandler; +import org.openqa.selenium.remote.http.HttpRequest; +import org.openqa.selenium.remote.http.HttpResponse; +import org.openqa.selenium.remote.http.Routable; + +/** Filter that blocks requests matching specified routes. */ +public class BlockedRoutesFilter implements HttpHandler { + + private static final Logger LOG = Logger.getLogger(BlockedRoutesFilter.class.getName()); + private final List blockedRoutes; + private final HttpHandler delegate; + + public BlockedRoutesFilter(List blockedRoutes, HttpHandler delegate) { + this.blockedRoutes = blockedRoutes; + this.delegate = delegate; + } + + @Override + public HttpResponse execute(HttpRequest request) { + String method = request.getMethod().toString(); + String path = URI.create(request.getUri()).getPath(); + + // Check if the request matches any blocked route + for (BlockedRoute blockedRoute : blockedRoutes) { + if (blockedRoute.matches(method, path)) { + LOG.warning( + "Blocked request: " + + method + + " " + + path + + " (matches blocked route: " + + blockedRoute + + ")"); + return new HttpResponse() + .setStatus(403) // Forbidden + .setContent( + Contents.utf8String("Route blocked by configuration: " + method + " " + path)); + } + } + + // If not blocked, delegate to the next handler + return delegate.execute(request); + } + + /** Creates a Routable that applies the blocked routes filter. */ + public static Routable with(Routable routable, List blockedRoutes) { + if (blockedRoutes == null || blockedRoutes.isEmpty()) { + return routable; + } + + return new Routable() { + @Override + public HttpResponse execute(HttpRequest req) { + return new BlockedRoutesFilter(blockedRoutes, routable).execute(req); + } + + @Override + public boolean matches(HttpRequest req) { + return routable.matches(req); + } + }; + } +} diff --git a/java/src/org/openqa/selenium/grid/router/httpd/RouterFlags.java b/java/src/org/openqa/selenium/grid/router/httpd/RouterFlags.java index 3960c59d00d31..febaeb0cb74cf 100644 --- a/java/src/org/openqa/selenium/grid/router/httpd/RouterFlags.java +++ b/java/src/org/openqa/selenium/grid/router/httpd/RouterFlags.java @@ -24,6 +24,7 @@ import com.beust.jcommander.Parameter; import com.google.auto.service.AutoService; import java.util.Collections; +import java.util.List; import java.util.Set; import org.openqa.selenium.grid.config.ConfigValue; import org.openqa.selenium.grid.config.HasRoles; @@ -74,6 +75,28 @@ public class RouterFlags implements HasRoles { @ConfigValue(section = ROUTER_SECTION, name = "disable-ui", example = "true") public boolean disableUi = false; + @Parameter( + names = {"--blocked-routes"}, + description = + "Route to block in format 'METHOD:path'. Can be specified multiple times." + + " Example: --blocked-routes DELETE:/session/{session-id} --blocked-routes" + + " DELETE:/se/grid/distributor/node/{node-id}") + @ConfigValue( + section = ROUTER_SECTION, + name = "blocked-routes", + example = + "[\"DELETE:/session/{session-id}\", \"DELETE:/se/grid/distributor/node/{node-id}\"]") + public List blockedRoutes; + + @Parameter( + names = {"--blocked-delete-session"}, + arity = 1, + description = + "A flag to prevent deleting a session proactively by blocking DELETE requests to" + + " /session/{session-id} route") + @ConfigValue(section = ROUTER_SECTION, name = "blocked-delete-session", example = "true") + public boolean blockedDeleteSession = false; + @Override public Set getRoles() { return Collections.singleton(ROUTER_ROLE); diff --git a/java/src/org/openqa/selenium/grid/router/httpd/RouterOptions.java b/java/src/org/openqa/selenium/grid/router/httpd/RouterOptions.java index 47d2e902a107a..b2be3ead493c5 100644 --- a/java/src/org/openqa/selenium/grid/router/httpd/RouterOptions.java +++ b/java/src/org/openqa/selenium/grid/router/httpd/RouterOptions.java @@ -17,6 +17,9 @@ package org.openqa.selenium.grid.router.httpd; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.openqa.selenium.grid.config.Config; public class RouterOptions { @@ -51,4 +54,51 @@ public String subPath() { public boolean disableUi() { return config.get(ROUTER_SECTION, "disable-ui").map(Boolean::parseBoolean).orElse(false); } + + /** + * Returns a list of blocked routes from the configuration. Each blocked route should be specified + * in the format "METHOD:path" (e.g., "DELETE:/session/{session-id}"). Multiple routes can be + * specified as a list. If the blocked-delete-session flag is enabled, + * DELETE:/session/{session-id} will be automatically added. + * + * @return List of blocked routes + */ + public List getBlockedRoutes() { + List routes = + config + .getAll(ROUTER_SECTION, "blocked-routes") + .map( + blockedRoutesList -> { + if (blockedRoutesList.isEmpty()) { + return List.of(); + } + + return blockedRoutesList.stream() + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(BlockedRoute::fromString) + .collect(Collectors.toList()); + }) + .orElse(List.of()); + + // Add DELETE session route if the flag is enabled + boolean blockedDeleteSession = + config.getBool(ROUTER_SECTION, "blocked-delete-session").orElse(false); + + if (blockedDeleteSession) { + BlockedRoute deleteSessionRoute = new BlockedRoute("DELETE", "/session/{session-id}"); + // Only add if not already present + if (routes.stream() + .noneMatch( + route -> + "DELETE".equals(route.getMethod()) + && "/session/{session-id}".equals(route.getPath()))) { + routes = + Stream.concat(routes.stream(), Stream.of(deleteSessionRoute)) + .collect(Collectors.toList()); + } + } + + return routes; + } } diff --git a/java/src/org/openqa/selenium/grid/router/httpd/RouterServer.java b/java/src/org/openqa/selenium/grid/router/httpd/RouterServer.java index 7ff73e655cb88..8f0f8218a6743 100644 --- a/java/src/org/openqa/selenium/grid/router/httpd/RouterServer.java +++ b/java/src/org/openqa/selenium/grid/router/httpd/RouterServer.java @@ -37,6 +37,7 @@ import java.net.URL; import java.time.Duration; import java.util.Collections; +import java.util.List; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; @@ -173,6 +174,13 @@ protected Handlers createHandlers(Config config) { route = route.with(new BasicAuthenticationFilter(uap.username(), uap.password())); } + // Apply blocked routes filter + List blockedRoutes = routerOptions.getBlockedRoutes(); + if (!blockedRoutes.isEmpty()) { + LOG.info("Blocking " + blockedRoutes.size() + " route(s): " + blockedRoutes); + route = BlockedRoutesFilter.with(route, blockedRoutes); + } + HttpHandler readinessCheck = req -> { boolean ready = router.isReady(); diff --git a/java/test/org/openqa/selenium/grid/router/httpd/BUILD.bazel b/java/test/org/openqa/selenium/grid/router/httpd/BUILD.bazel new file mode 100644 index 0000000000000..bb3eb61fe3811 --- /dev/null +++ b/java/test/org/openqa/selenium/grid/router/httpd/BUILD.bazel @@ -0,0 +1,18 @@ +load("@rules_jvm_external//:defs.bzl", "artifact") +load("//java:defs.bzl", "JUNIT5_DEPS", "java_test_suite") + +java_test_suite( + name = "httpd-tests", + size = "small", + srcs = glob(["*Test.java"]), + deps = [ + "//java/src/org/openqa/selenium/grid/config", + "//java/src/org/openqa/selenium/grid/router/httpd", + "//java/src/org/openqa/selenium/remote/http", + "//java/test/org/openqa/selenium/testing:test-base", + artifact("com.google.guava:guava"), + artifact("org.junit.jupiter:junit-jupiter-api"), + artifact("org.assertj:assertj-core"), + artifact("org.mockito:mockito-core"), + ] + JUNIT5_DEPS, +) diff --git a/java/test/org/openqa/selenium/grid/router/httpd/BlockedRouteTest.java b/java/test/org/openqa/selenium/grid/router/httpd/BlockedRouteTest.java new file mode 100644 index 0000000000000..d0fc077c7171f --- /dev/null +++ b/java/test/org/openqa/selenium/grid/router/httpd/BlockedRouteTest.java @@ -0,0 +1,166 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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.openqa.selenium.grid.router.httpd; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class BlockedRouteTest { + + @Test + void matchesExactPathAndMethod() { + BlockedRoute route = new BlockedRoute("GET", "/status"); + assertTrue(route.matches("GET", "/status")); + assertFalse(route.matches("POST", "/status")); + assertFalse(route.matches("GET", "/not-status")); + } + + @Test + void matchesPathWithParameter() { + BlockedRoute route = new BlockedRoute("DELETE", "/session/{session-id}"); + assertTrue(route.matches("DELETE", "/session/123")); + assertTrue(route.matches("DELETE", "/session/abc")); + assertFalse(route.matches("DELETE", "/session/")); + assertFalse(route.matches("DELETE", "/session")); + assertFalse(route.matches("DELETE", "/session/123/extra")); + } + + @Test + void methodMatchingIsCaseInsensitive() { + BlockedRoute route = new BlockedRoute("delete", "/session/{session-id}"); + assertTrue(route.matches("DELETE", "/session/123")); + assertTrue(route.matches("delete", "/session/123")); + } + + @Test + void doesNotMatchIfSegmentCountDiffers() { + BlockedRoute route = new BlockedRoute("GET", "/foo/{bar}"); + assertFalse(route.matches("GET", "/foo")); + assertFalse(route.matches("GET", "/foo/bar/baz")); + } + + @Test + void handlesLeadingAndTrailingSlashes() { + BlockedRoute route = new BlockedRoute("GET", "/foo/{bar}/"); + assertTrue(route.matches("GET", "/foo/123/")); + assertTrue(route.matches("GET", "/foo/123")); + assertFalse(route.matches("GET", "/foo//")); + } + + @Test + void doesNotReDoSOnLongInput() { + BlockedRoute route = new BlockedRoute("GET", "/foo/{bar}"); + String longPath = "/foo/" + "a".repeat(10000); + assertTrue(route.matches("GET", longPath)); + } + + @Test + void preventsPathTraversalWithDoubleSlashes() { + BlockedRoute route = new BlockedRoute("GET", "/admin/users"); + // These should all be normalized to /admin/users and match + assertTrue(route.matches("GET", "/admin//users")); + assertTrue(route.matches("GET", "//admin//users")); + assertTrue(route.matches("GET", "/admin/users//")); + assertTrue(route.matches("GET", "///admin///users///")); + } + + @Test + void preventsPathTraversalWithEncodedCharacters() { + BlockedRoute route = new BlockedRoute("GET", "/admin/users"); + // %2F is URL-encoded forward slash + assertTrue(route.matches("GET", "/admin%2Fusers")); + assertTrue(route.matches("GET", "%2Fadmin%2Fusers")); + assertTrue(route.matches("GET", "/admin%2F%2Fusers")); + } + + @Test + void preventsPathTraversalWithDotDotSequences() { + BlockedRoute route = new BlockedRoute("GET", "/admin/users"); + // These should be normalized and match + assertTrue(route.matches("GET", "/admin/../admin/users")); + assertTrue(route.matches("GET", "/admin/./users")); + assertTrue(route.matches("GET", "/admin/../admin/./users")); + } + + @Test + void preventsPathTraversalAboveRoot() { + BlockedRoute route = new BlockedRoute("GET", "/admin/users"); + // These should throw IllegalArgumentException due to path traversal above root + assertThrows( + IllegalArgumentException.class, () -> route.matches("GET", "/admin/../../etc/passwd")); + assertThrows(IllegalArgumentException.class, () -> route.matches("GET", "../../../etc/passwd")); + assertThrows(IllegalArgumentException.class, () -> route.matches("GET", "/admin/../../")); + } + + @Test + void handlesComplexPathTraversalAttempts() { + BlockedRoute route = new BlockedRoute("GET", "/api/data"); + // Complex combinations of traversal techniques + assertTrue(route.matches("GET", "/api//data")); + assertTrue(route.matches("GET", "/api/./data")); + assertTrue(route.matches("GET", "/api/../api/data")); + assertTrue(route.matches("GET", "/api%2Fdata")); + assertTrue(route.matches("GET", "/api%2F%2Fdata")); + } + + @Test + void normalizesMultipleTraversalSequences() { + BlockedRoute route = new BlockedRoute("GET", "/admin/users"); + // Multiple ../ sequences should be resolved correctly + assertTrue(route.matches("GET", "/admin/../admin/../admin/users")); + assertTrue(route.matches("GET", "/admin/././users")); + assertTrue(route.matches("GET", "/admin/../admin/./users")); + } + + @Test + void handlesEmptyAndNullPaths() { + BlockedRoute route = new BlockedRoute("GET", "/"); + assertTrue(route.matches("GET", "")); + assertTrue(route.matches("GET", null)); + } + + @Test + void preventsPathTraversalInParameterizedRoutes() { + BlockedRoute route = new BlockedRoute("DELETE", "/session/{session-id}"); + // Path traversal attempts in parameterized routes should be normalized + assertTrue(route.matches("DELETE", "/session//123")); + assertTrue(route.matches("DELETE", "/session/./123")); + assertTrue(route.matches("DELETE", "/session/../session/123")); + assertTrue(route.matches("DELETE", "/session%2F123")); + } + + @Test + void handlesMixedTraversalTechniques() { + BlockedRoute route = new BlockedRoute("GET", "/admin/users"); + // Mixed techniques should all be normalized correctly + assertTrue(route.matches("GET", "/admin//./../admin//users")); + assertTrue(route.matches("GET", "/admin%2F%2F./../admin/users")); + assertTrue(route.matches("GET", "/admin//%2F./../admin/users")); + } + + @Test + void fromStringHandlesPathTraversalInInput() { + // Test that fromString method can handle path traversal in the input string + BlockedRoute route = BlockedRoute.fromString("GET:/admin//users"); + assertTrue(route.matches("GET", "/admin/users")); + + route = BlockedRoute.fromString("GET:/admin%2Fusers"); + assertTrue(route.matches("GET", "/admin/users")); + } +} diff --git a/java/test/org/openqa/selenium/grid/router/httpd/BlockedRoutesFilterTest.java b/java/test/org/openqa/selenium/grid/router/httpd/BlockedRoutesFilterTest.java new file mode 100644 index 0000000000000..091c9b40c86aa --- /dev/null +++ b/java/test/org/openqa/selenium/grid/router/httpd/BlockedRoutesFilterTest.java @@ -0,0 +1,213 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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.openqa.selenium.grid.router.httpd; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.openqa.selenium.remote.http.HttpMethod.DELETE; +import static org.openqa.selenium.remote.http.HttpMethod.GET; +import static org.openqa.selenium.remote.http.HttpMethod.POST; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.remote.http.HttpHandler; +import org.openqa.selenium.remote.http.HttpRequest; +import org.openqa.selenium.remote.http.HttpResponse; +import org.openqa.selenium.remote.http.Routable; + +class BlockedRoutesFilterTest { + + @Test + void shouldBlockMatchingRequest() { + List blockedRoutes = + List.of(BlockedRoute.fromString("DELETE:/session/{session-id}")); + + HttpHandler mockHandler = mock(HttpHandler.class); + + BlockedRoutesFilter filter = new BlockedRoutesFilter(blockedRoutes, mockHandler); + + HttpRequest request = new HttpRequest(DELETE, "/session/123"); + + HttpResponse response = filter.execute(request); + + assertEquals(403, response.getStatus()); + assertNotNull(response.getContent()); + verify(mockHandler, never()).execute(request); + } + + @Test + void shouldAllowNonMatchingRequest() { + List blockedRoutes = + List.of(BlockedRoute.fromString("DELETE:/session/{session-id}")); + + HttpHandler mockHandler = mock(HttpHandler.class); + HttpResponse expectedResponse = new HttpResponse().setStatus(200); + when(mockHandler.execute(any(HttpRequest.class))).thenReturn(expectedResponse); + + BlockedRoutesFilter filter = new BlockedRoutesFilter(blockedRoutes, mockHandler); + + HttpRequest request = new HttpRequest(GET, "/status"); + + HttpResponse response = filter.execute(request); + + assertEquals(200, response.getStatus()); + } + + @Test + void shouldAllowRequestWithDifferentMethod() { + List blockedRoutes = + List.of(BlockedRoute.fromString("DELETE:/session/{session-id}")); + + HttpHandler mockHandler = mock(HttpHandler.class); + HttpResponse expectedResponse = new HttpResponse().setStatus(200); + when(mockHandler.execute(any(HttpRequest.class))).thenReturn(expectedResponse); + + BlockedRoutesFilter filter = new BlockedRoutesFilter(blockedRoutes, mockHandler); + + HttpRequest request = new HttpRequest(POST, "/session/123"); + + HttpResponse response = filter.execute(request); + + assertEquals(200, response.getStatus()); + } + + @Test + void shouldAllowRequestWithDifferentPath() { + List blockedRoutes = + List.of(BlockedRoute.fromString("DELETE:/session/{session-id}")); + + HttpHandler mockHandler = mock(HttpHandler.class); + HttpResponse expectedResponse = new HttpResponse().setStatus(200); + when(mockHandler.execute(any(HttpRequest.class))).thenReturn(expectedResponse); + + BlockedRoutesFilter filter = new BlockedRoutesFilter(blockedRoutes, mockHandler); + + HttpRequest request = new HttpRequest(DELETE, "/session"); + + HttpResponse response = filter.execute(request); + + assertEquals(200, response.getStatus()); + } + + @Test + void shouldBlockMultipleMatchingRoutes() { + List blockedRoutes = + List.of( + BlockedRoute.fromString("DELETE:/session/{session-id}"), + BlockedRoute.fromString("POST:/session")); + + HttpHandler mockHandler = mock(HttpHandler.class); + + BlockedRoutesFilter filter = new BlockedRoutesFilter(blockedRoutes, mockHandler); + + // Test first blocked route + HttpRequest deleteRequest = new HttpRequest(DELETE, "/session/123"); + + HttpResponse deleteResponse = filter.execute(deleteRequest); + assertEquals(403, deleteResponse.getStatus()); + + // Test second blocked route + HttpRequest postRequest = new HttpRequest(POST, "/session"); + + HttpResponse postResponse = filter.execute(postRequest); + assertEquals(403, postResponse.getStatus()); + } + + @Test + void shouldReturnOriginalRoutableWhenNoBlockedRoutes() { + List blockedRoutes = List.of(); + + // Create a real Routable implementation + Routable originalRoutable = + new Routable() { + @Override + public HttpResponse execute(HttpRequest req) { + return new HttpResponse().setStatus(200); + } + + @Override + public boolean matches(HttpRequest req) { + return true; + } + }; + + Routable result = BlockedRoutesFilter.with(originalRoutable, blockedRoutes); + + assertEquals(originalRoutable, result); + } + + @Test + void shouldReturnOriginalRoutableWhenBlockedRoutesIsNull() { + // Create a real Routable implementation + Routable originalRoutable = + new Routable() { + @Override + public HttpResponse execute(HttpRequest req) { + return new HttpResponse().setStatus(200); + } + + @Override + public boolean matches(HttpRequest req) { + return true; + } + }; + + Routable result = BlockedRoutesFilter.with(originalRoutable, null); + + assertEquals(originalRoutable, result); + } + + @Test + void shouldHandleCaseInsensitiveMethodMatching() { + List blockedRoutes = + List.of(BlockedRoute.fromString("DELETE:/session/{session-id}")); + + HttpHandler mockHandler = mock(HttpHandler.class); + + BlockedRoutesFilter filter = new BlockedRoutesFilter(blockedRoutes, mockHandler); + + HttpRequest request = new HttpRequest(DELETE, "/session/123"); + + HttpResponse response = filter.execute(request); + + assertEquals(403, response.getStatus()); + } + + @Test + void shouldNotBlockRequestWithFollowPath() { + List blockedRoutes = + List.of(BlockedRoute.fromString("DELETE:/session/{session-id}")); + + HttpHandler mockHandler = mock(HttpHandler.class); + HttpResponse expectedResponse = new HttpResponse().setStatus(200); + when(mockHandler.execute(any(HttpRequest.class))).thenReturn(expectedResponse); + + BlockedRoutesFilter filter = new BlockedRoutesFilter(blockedRoutes, mockHandler); + + HttpRequest request = new HttpRequest(DELETE, "/session/123/se/bidi"); + + HttpResponse response = filter.execute(request); + + assertEquals(200, response.getStatus()); + } +} diff --git a/java/test/org/openqa/selenium/grid/router/httpd/RouterOptionsTest.java b/java/test/org/openqa/selenium/grid/router/httpd/RouterOptionsTest.java new file mode 100644 index 0000000000000..83f5899a20a63 --- /dev/null +++ b/java/test/org/openqa/selenium/grid/router/httpd/RouterOptionsTest.java @@ -0,0 +1,417 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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.openqa.selenium.grid.router.httpd; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.io.StringReader; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.grid.config.Config; +import org.openqa.selenium.grid.config.MapConfig; +import org.openqa.selenium.grid.config.TomlConfig; + +class RouterOptionsTest { + + @Test + void shouldReturnEmptyListWhenNoBlockedRoutesConfigured() { + Config config = new MapConfig(ImmutableMap.of()); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).isEmpty(); + } + + @Test + void shouldParseSingleBlockedRoute() { + Config config = + new MapConfig( + ImmutableMap.of( + "router", + ImmutableMap.of( + "blocked-routes", ImmutableList.of("DELETE:/session/{session-id}")))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).hasSize(1); + + BlockedRoute route = blockedRoutes.get(0); + assertEquals("DELETE", route.getMethod()); + assertEquals("/session/{session-id}", route.getPath()); + } + + @Test + void shouldParseMultipleBlockedRoutes() { + Config config = + new MapConfig( + ImmutableMap.of( + "router", + ImmutableMap.of( + "blocked-routes", + ImmutableList.of( + "DELETE:/session/{session-id}", "POST:/session", "GET:/status")))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).hasSize(3); + + assertThat(blockedRoutes) + .anyMatch( + route -> + "DELETE".equals(route.getMethod()) + && "/session/{session-id}".equals(route.getPath())); + assertThat(blockedRoutes) + .anyMatch(route -> "POST".equals(route.getMethod()) && "/session".equals(route.getPath())); + assertThat(blockedRoutes) + .anyMatch(route -> "GET".equals(route.getMethod()) && "/status".equals(route.getPath())); + } + + @Test + void shouldHandleWhitespaceInBlockedRoutes() { + Config config = + new MapConfig( + ImmutableMap.of( + "router", + ImmutableMap.of( + "blocked-routes", + ImmutableList.of(" DELETE : /session/{session-id} ", " POST : /session ")))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).hasSize(2); + + assertThat(blockedRoutes) + .anyMatch( + route -> + "DELETE".equals(route.getMethod()) + && "/session/{session-id}".equals(route.getPath())); + assertThat(blockedRoutes) + .anyMatch(route -> "POST".equals(route.getMethod()) && "/session".equals(route.getPath())); + } + + @Test + void shouldIgnoreEmptyEntriesInBlockedRoutes() { + Config config = + new MapConfig( + ImmutableMap.of( + "router", + ImmutableMap.of( + "blocked-routes", + ImmutableList.of("DELETE:/session/{session-id}", "", "POST:/session")))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).hasSize(2); + } + + @Test + void shouldHandleNullBlockedRoutes() { + // Test that when blocked-routes is not present in config, it returns empty list + Config config = new MapConfig(ImmutableMap.of("router", ImmutableMap.of())); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).isEmpty(); + } + + @Test + void shouldHandleEmptyBlockedRoutes() { + Config config = + new MapConfig( + ImmutableMap.of("router", ImmutableMap.of("blocked-routes", ImmutableList.of()))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).isEmpty(); + } + + @Test + void shouldParseBlockedRoutesFromTomlConfig() { + String[] rawConfig = { + "[router]", + "blocked-routes = [", + " \"DELETE:/session/{session-id}\",", + " \"POST:/session\",", + " \"GET:/status\"", + "]" + }; + Config config = new TomlConfig(new StringReader(String.join("\n", rawConfig))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).hasSize(3); + + assertThat(blockedRoutes) + .anyMatch( + route -> + "DELETE".equals(route.getMethod()) + && "/session/{session-id}".equals(route.getPath())); + assertThat(blockedRoutes) + .anyMatch(route -> "POST".equals(route.getMethod()) && "/session".equals(route.getPath())); + assertThat(blockedRoutes) + .anyMatch(route -> "GET".equals(route.getMethod()) && "/status".equals(route.getPath())); + } + + @Test + void shouldParseBlockedRoutesFromTomlConfigWithWhitespace() { + String[] rawConfig = { + "[router]", + "blocked-routes = [", + " \" DELETE : /session/{session-id} \",", + " \" POST : /session \"", + "]" + }; + Config config = new TomlConfig(new StringReader(String.join("\n", rawConfig))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).hasSize(2); + + assertThat(blockedRoutes) + .anyMatch( + route -> + "DELETE".equals(route.getMethod()) + && "/session/{session-id}".equals(route.getPath())); + assertThat(blockedRoutes) + .anyMatch(route -> "POST".equals(route.getMethod()) && "/session".equals(route.getPath())); + } + + @Test + void shouldParseBlockedRoutesFromTomlConfigWithEmptyEntries() { + String[] rawConfig = { + "[router]", + "blocked-routes = [", + " \"DELETE:/session/{session-id}\",", + " \"\",", + " \"POST:/session\"", + "]" + }; + Config config = new TomlConfig(new StringReader(String.join("\n", rawConfig))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).hasSize(2); + } + + @Test + void shouldParseBlockedRoutesFromTomlConfigWithDeleteSessionFlag() { + String[] rawConfig = { + "[router]", + "blocked-routes = [", + " \"POST:/session\",", + " \"GET:/status\"", + "]", + "blocked-delete-session = true" + }; + Config config = new TomlConfig(new StringReader(String.join("\n", rawConfig))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).hasSize(3); + + assertThat(blockedRoutes) + .anyMatch( + route -> + "DELETE".equals(route.getMethod()) + && "/session/{session-id}".equals(route.getPath())); + assertThat(blockedRoutes) + .anyMatch(route -> "POST".equals(route.getMethod()) && "/session".equals(route.getPath())); + assertThat(blockedRoutes) + .anyMatch(route -> "GET".equals(route.getMethod()) && "/status".equals(route.getPath())); + } + + @Test + void shouldParseBlockedRoutesFromTomlConfigWithSingleRoute() { + String[] rawConfig = {"[router]", "blocked-routes = [\"DELETE:/session/{session-id}\"]"}; + Config config = new TomlConfig(new StringReader(String.join("\n", rawConfig))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).hasSize(1); + + BlockedRoute route = blockedRoutes.get(0); + assertEquals("DELETE", route.getMethod()); + assertEquals("/session/{session-id}", route.getPath()); + } + + @Test + void shouldParseEmptyBlockedRoutesFromTomlConfig() { + String[] rawConfig = {"[router]", "blocked-routes = []"}; + Config config = new TomlConfig(new StringReader(String.join("\n", rawConfig))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).isEmpty(); + } + + @Test + void shouldParseBlockedRoutesFromTomlConfigWithoutRouterSection() { + String[] rawConfig = {"[other]", "some-option = \"value\""}; + Config config = new TomlConfig(new StringReader(String.join("\n", rawConfig))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).isEmpty(); + } + + @Test + void blockedRouteShouldMatchExactPath() { + BlockedRoute route = BlockedRoute.fromString("DELETE:/session/{session-id}"); + + assertTrue(route.matches("DELETE", "/session/123")); + assertTrue(route.matches("DELETE", "/session/abc-def")); + assertFalse(route.matches("DELETE", "/session")); + assertFalse(route.matches("DELETE", "/session/123/extra")); + assertFalse(route.matches("POST", "/session/123")); + } + + @Test + void blockedRouteShouldMatchExactPathWithoutParameters() { + BlockedRoute route = BlockedRoute.fromString("POST:/session"); + + assertTrue(route.matches("POST", "/session")); + assertFalse(route.matches("POST", "/session/123")); + assertFalse(route.matches("GET", "/session")); + } + + @Test + void blockedRouteShouldBeCaseInsensitiveForMethod() { + BlockedRoute route = BlockedRoute.fromString("DELETE:/session/{session-id}"); + + assertTrue(route.matches("delete", "/session/123")); + assertTrue(route.matches("Delete", "/session/123")); + assertTrue(route.matches("DELETE", "/session/123")); + } + + @Test + void shouldThrowExceptionForInvalidRouteFormat() { + assertThrows(IllegalArgumentException.class, () -> BlockedRoute.fromString("DELETE")); + + assertThrows(IllegalArgumentException.class, () -> BlockedRoute.fromString(":path")); + + assertThrows(IllegalArgumentException.class, () -> BlockedRoute.fromString("method:")); + + assertThrows(IllegalArgumentException.class, () -> BlockedRoute.fromString("")); + + assertThrows(IllegalArgumentException.class, () -> BlockedRoute.fromString(null)); + } + + @Test + void blockedRouteToStringShouldReturnOriginalFormat() { + BlockedRoute route = new BlockedRoute("DELETE", "/session/{session-id}"); + assertEquals("DELETE:/session/{session-id}", route.toString()); + } + + @Test + void blockedRouteShouldHandleMultiplePathParameters() { + BlockedRoute route = BlockedRoute.fromString("PUT:/session/{session-id}/element/{element-id}"); + + assertTrue(route.matches("PUT", "/session/123/element/456")); + assertTrue(route.matches("PUT", "/session/abc/element/def")); + assertFalse(route.matches("PUT", "/session/123/element")); + assertFalse(route.matches("PUT", "/session/element/456")); + } + + @Test + void shouldAddDeleteSessionRouteWhenFlagIsEnabled() { + Config config = + new MapConfig(ImmutableMap.of("router", ImmutableMap.of("blocked-delete-session", "true"))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).hasSize(1); + + BlockedRoute route = blockedRoutes.get(0); + assertEquals("DELETE", route.getMethod()); + assertEquals("/session/{session-id}", route.getPath()); + } + + @Test + void shouldNotAddDuplicateDeleteSessionRoute() { + Config config = + new MapConfig( + ImmutableMap.of( + "router", + ImmutableMap.of( + "blocked-routes", + ImmutableList.of("DELETE:/session/{session-id}"), + "blocked-delete-session", + "true"))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).hasSize(1); + + BlockedRoute route = blockedRoutes.get(0); + assertEquals("DELETE", route.getMethod()); + assertEquals("/session/{session-id}", route.getPath()); + } + + @Test + void shouldCombineBlockedRoutesWithDeleteSessionFlag() { + Config config = + new MapConfig( + ImmutableMap.of( + "router", + ImmutableMap.of( + "blocked-routes", + ImmutableList.of("POST:/session", "GET:/status"), + "blocked-delete-session", + "true"))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).hasSize(3); + + assertThat(blockedRoutes) + .anyMatch( + route -> + "DELETE".equals(route.getMethod()) + && "/session/{session-id}".equals(route.getPath())); + assertThat(blockedRoutes) + .anyMatch(route -> "POST".equals(route.getMethod()) && "/session".equals(route.getPath())); + assertThat(blockedRoutes) + .anyMatch(route -> "GET".equals(route.getMethod()) && "/status".equals(route.getPath())); + } + + @Test + void shouldNotAddDeleteSessionRouteWhenFlagIsDisabled() { + Config config = + new MapConfig( + ImmutableMap.of("router", ImmutableMap.of("blocked-delete-session", "false"))); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).isEmpty(); + } + + @Test + void shouldNotAddDeleteSessionRouteWhenFlagIsNotSet() { + Config config = new MapConfig(ImmutableMap.of()); + RouterOptions options = new RouterOptions(config); + + List blockedRoutes = options.getBlockedRoutes(); + assertThat(blockedRoutes).isEmpty(); + } +}