Skip to content

Commit 406427b

Browse files
authored
[grid] Add config blocked-routes and specific blocked-delete-session in Router (#15920)
Signed-off-by: Viet Nguyen Duc <nguyenducviet4496@gmail.com>
1 parent 5e078b2 commit 406427b

File tree

11 files changed

+1222
-0
lines changed

11 files changed

+1222
-0
lines changed

java/src/org/openqa/selenium/grid/commands/Hub.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import java.net.MalformedURLException;
3232
import java.net.URL;
3333
import java.util.Collections;
34+
import java.util.List;
3435
import java.util.Set;
3536
import java.util.logging.Level;
3637
import java.util.logging.Logger;
@@ -48,6 +49,8 @@
4849
import org.openqa.selenium.grid.log.LoggingOptions;
4950
import org.openqa.selenium.grid.router.ProxyWebsocketsIntoGrid;
5051
import org.openqa.selenium.grid.router.Router;
52+
import org.openqa.selenium.grid.router.httpd.BlockedRoute;
53+
import org.openqa.selenium.grid.router.httpd.BlockedRoutesFilter;
5154
import org.openqa.selenium.grid.router.httpd.RouterOptions;
5255
import org.openqa.selenium.grid.security.BasicAuthenticationFilter;
5356
import org.openqa.selenium.grid.security.Secret;
@@ -207,6 +210,13 @@ protected Handlers createHandlers(Config config) {
207210
httpHandler = httpHandler.with(new BasicAuthenticationFilter(uap.username(), uap.password()));
208211
}
209212

213+
// Apply blocked routes filter
214+
List<BlockedRoute> blockedRoutes = routerOptions.getBlockedRoutes();
215+
if (!blockedRoutes.isEmpty()) {
216+
LOG.info("Blocking " + blockedRoutes.size() + " route(s): " + blockedRoutes);
217+
httpHandler = BlockedRoutesFilter.with(httpHandler, blockedRoutes);
218+
}
219+
210220
// Allow the liveness endpoint to be reached, since k8s doesn't make it easy to authenticate
211221
// these checks
212222
httpHandler = combine(httpHandler, Route.get("/readyz").to(() -> readinessCheck));

java/src/org/openqa/selenium/grid/commands/Standalone.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import java.net.URI;
3333
import java.net.URL;
3434
import java.util.Collections;
35+
import java.util.List;
3536
import java.util.Set;
3637
import java.util.logging.Level;
3738
import java.util.logging.Logger;
@@ -53,6 +54,8 @@
5354
import org.openqa.selenium.grid.node.ProxyNodeWebsockets;
5455
import org.openqa.selenium.grid.node.config.NodeOptions;
5556
import org.openqa.selenium.grid.router.Router;
57+
import org.openqa.selenium.grid.router.httpd.BlockedRoute;
58+
import org.openqa.selenium.grid.router.httpd.BlockedRoutesFilter;
5659
import org.openqa.selenium.grid.router.httpd.RouterOptions;
5760
import org.openqa.selenium.grid.security.BasicAuthenticationFilter;
5861
import org.openqa.selenium.grid.security.Secret;
@@ -213,6 +216,13 @@ protected Handlers createHandlers(Config config) {
213216
httpHandler = httpHandler.with(new BasicAuthenticationFilter(uap.username(), uap.password()));
214217
}
215218

219+
// Apply blocked routes filter
220+
List<BlockedRoute> blockedRoutes = routerOptions.getBlockedRoutes();
221+
if (!blockedRoutes.isEmpty()) {
222+
LOG.info("Blocking " + blockedRoutes.size() + " route(s): " + blockedRoutes);
223+
httpHandler = BlockedRoutesFilter.with(httpHandler, blockedRoutes);
224+
}
225+
216226
// Allow the liveness endpoint to be reached, since k8s doesn't make it easy to authenticate
217227
// these checks
218228
httpHandler = combine(httpHandler, Route.get("/readyz").to(() -> readinessCheck));
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Licensed to the Software Freedom Conservancy (SFC) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The SFC licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package org.openqa.selenium.grid.router.httpd;
19+
20+
import java.net.URLDecoder;
21+
import java.nio.charset.StandardCharsets;
22+
import java.util.ArrayList;
23+
import java.util.List;
24+
25+
/** Represents a blocked route with HTTP method and path. */
26+
public class BlockedRoute {
27+
private final String method;
28+
private final String path;
29+
30+
public BlockedRoute(String method, String path) {
31+
this.method = method.toUpperCase();
32+
this.path = path;
33+
}
34+
35+
public String getMethod() {
36+
return method;
37+
}
38+
39+
public String getPath() {
40+
return path;
41+
}
42+
43+
/**
44+
* Creates a BlockedRoute from a string in the format "METHOD:path".
45+
*
46+
* @param routeStr String representation of blocked route
47+
* @return BlockedRoute instance
48+
* @throws IllegalArgumentException if the format is invalid
49+
*/
50+
public static BlockedRoute fromString(String routeStr) {
51+
if (routeStr == null || routeStr.trim().isEmpty()) {
52+
throw new IllegalArgumentException("Route string cannot be null or empty");
53+
}
54+
55+
String[] parts = routeStr.split(":", 2);
56+
if (parts.length != 2) {
57+
throw new IllegalArgumentException(
58+
"Invalid route format. Expected 'METHOD:path', got: " + routeStr);
59+
}
60+
61+
String method = parts[0].trim().toUpperCase();
62+
String path = parts[1].trim();
63+
64+
if (method.isEmpty() || path.isEmpty()) {
65+
throw new IllegalArgumentException("Method and path cannot be empty. Got: " + routeStr);
66+
}
67+
68+
return new BlockedRoute(method, path);
69+
}
70+
71+
/**
72+
* Checks if the given HTTP method and request path match this blocked route.
73+
*
74+
* @param requestMethod HTTP method of the request
75+
* @param requestPath Path of the request
76+
* @return true if the route should be blocked
77+
*/
78+
public boolean matches(String requestMethod, String requestPath) {
79+
if (!method.equals(requestMethod.toUpperCase())) {
80+
return false;
81+
}
82+
83+
// Use safe string-based path matching instead of regex to prevent ReDoS attacks
84+
return matchesPathPattern(path, requestPath);
85+
}
86+
87+
/**
88+
* Safely matches a path pattern against a request path without using regex. Handles path
89+
* parameters like {session-id} by treating them as wildcards. Both paths are normalized to
90+
* prevent path traversal attacks.
91+
*
92+
* @param pattern The path pattern to match against
93+
* @param requestPath The actual request path
94+
* @return true if the paths match
95+
*/
96+
private boolean matchesPathPattern(String pattern, String requestPath) {
97+
// Normalize both paths to prevent path traversal attacks
98+
String normalizedPattern = normalizePath(pattern);
99+
String normalizedRequestPath = normalizePath(requestPath);
100+
101+
// Split both paths into segments
102+
String[] patternSegments = normalizedPattern.split("/", -1); // keep trailing empty segments
103+
String[] requestSegments = normalizedRequestPath.split("/", -1);
104+
105+
// Paths must have the same number of segments
106+
if (patternSegments.length != requestSegments.length) {
107+
return false;
108+
}
109+
110+
// Compare each segment
111+
for (int i = 0; i < patternSegments.length; i++) {
112+
String patternSegment = patternSegments[i];
113+
String requestSegment = requestSegments[i];
114+
115+
// If both are empty (leading/trailing slash), continue
116+
if (patternSegment.isEmpty() && requestSegment.isEmpty()) {
117+
continue;
118+
}
119+
// If pattern segment is a path parameter (enclosed in {}), it matches any non-empty segment
120+
if (isPathParameter(patternSegment)) {
121+
if (requestSegment.isEmpty()) {
122+
return false;
123+
}
124+
} else {
125+
// For literal segments, they must match exactly
126+
if (!patternSegment.equals(requestSegment)) {
127+
return false;
128+
}
129+
}
130+
}
131+
132+
return true;
133+
}
134+
135+
/**
136+
* Normalizes a path to prevent path traversal attacks. This method: 1. URL decodes
137+
* percent-encoded characters 2. Normalizes multiple consecutive slashes to single slashes 3.
138+
* Resolves path traversal sequences (../) 4. Ensures the path doesn't escape the root directory
139+
*
140+
* @param path The path to normalize
141+
* @return The normalized path
142+
* @throws IllegalArgumentException if the path contains invalid traversal sequences
143+
*/
144+
private String normalizePath(String path) {
145+
if (path == null || path.isEmpty()) {
146+
return "/";
147+
}
148+
149+
try {
150+
// URL decode the path to handle percent-encoded characters like %2F
151+
String decodedPath = URLDecoder.decode(path, StandardCharsets.UTF_8);
152+
153+
// Normalize multiple consecutive slashes to single slashes
154+
String normalizedPath = decodedPath.replaceAll("/+", "/");
155+
156+
// Split into segments and resolve path traversal
157+
String[] segments = normalizedPath.split("/");
158+
List<String> resolvedSegments = new ArrayList<>();
159+
160+
for (String segment : segments) {
161+
if (segment.isEmpty() || ".".equals(segment)) {
162+
// Skip empty segments and current directory references
163+
continue;
164+
} else if ("..".equals(segment)) {
165+
// Go up one directory level
166+
if (!resolvedSegments.isEmpty()) {
167+
resolvedSegments.remove(resolvedSegments.size() - 1);
168+
} else {
169+
// Attempting to go above root - this is a security violation
170+
throw new IllegalArgumentException("Path traversal attack detected: " + path);
171+
}
172+
} else {
173+
// Add normal segment
174+
resolvedSegments.add(segment);
175+
}
176+
}
177+
178+
// Reconstruct the path
179+
StringBuilder result = new StringBuilder();
180+
for (String segment : resolvedSegments) {
181+
result.append("/").append(segment);
182+
}
183+
184+
// Ensure the result starts with / and handle empty path case
185+
String finalPath = result.toString();
186+
return finalPath.isEmpty() ? "/" : finalPath;
187+
188+
} catch (Exception e) {
189+
// If URL decoding fails or any other error occurs, throw security exception
190+
throw new IllegalArgumentException("Invalid path format: " + path, e);
191+
}
192+
}
193+
194+
/**
195+
* Checks if a path segment is a path parameter (enclosed in curly braces).
196+
*
197+
* @param segment The path segment to check
198+
* @return true if it's a path parameter
199+
*/
200+
private boolean isPathParameter(String segment) {
201+
return segment.startsWith("{") && segment.endsWith("}") && segment.length() > 2;
202+
}
203+
204+
@Override
205+
public String toString() {
206+
return method + ":" + path;
207+
}
208+
209+
@Override
210+
public boolean equals(Object obj) {
211+
if (this == obj) return true;
212+
if (obj == null || getClass() != obj.getClass()) return false;
213+
BlockedRoute that = (BlockedRoute) obj;
214+
return method.equals(that.method) && path.equals(that.path);
215+
}
216+
217+
@Override
218+
public int hashCode() {
219+
return method.hashCode() * 31 + path.hashCode();
220+
}
221+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Licensed to the Software Freedom Conservancy (SFC) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The SFC licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package org.openqa.selenium.grid.router.httpd;
19+
20+
import java.net.URI;
21+
import java.util.List;
22+
import java.util.logging.Logger;
23+
import org.openqa.selenium.remote.http.Contents;
24+
import org.openqa.selenium.remote.http.HttpHandler;
25+
import org.openqa.selenium.remote.http.HttpRequest;
26+
import org.openqa.selenium.remote.http.HttpResponse;
27+
import org.openqa.selenium.remote.http.Routable;
28+
29+
/** Filter that blocks requests matching specified routes. */
30+
public class BlockedRoutesFilter implements HttpHandler {
31+
32+
private static final Logger LOG = Logger.getLogger(BlockedRoutesFilter.class.getName());
33+
private final List<BlockedRoute> blockedRoutes;
34+
private final HttpHandler delegate;
35+
36+
public BlockedRoutesFilter(List<BlockedRoute> blockedRoutes, HttpHandler delegate) {
37+
this.blockedRoutes = blockedRoutes;
38+
this.delegate = delegate;
39+
}
40+
41+
@Override
42+
public HttpResponse execute(HttpRequest request) {
43+
String method = request.getMethod().toString();
44+
String path = URI.create(request.getUri()).getPath();
45+
46+
// Check if the request matches any blocked route
47+
for (BlockedRoute blockedRoute : blockedRoutes) {
48+
if (blockedRoute.matches(method, path)) {
49+
LOG.warning(
50+
"Blocked request: "
51+
+ method
52+
+ " "
53+
+ path
54+
+ " (matches blocked route: "
55+
+ blockedRoute
56+
+ ")");
57+
return new HttpResponse()
58+
.setStatus(403) // Forbidden
59+
.setContent(
60+
Contents.utf8String("Route blocked by configuration: " + method + " " + path));
61+
}
62+
}
63+
64+
// If not blocked, delegate to the next handler
65+
return delegate.execute(request);
66+
}
67+
68+
/** Creates a Routable that applies the blocked routes filter. */
69+
public static Routable with(Routable routable, List<BlockedRoute> blockedRoutes) {
70+
if (blockedRoutes == null || blockedRoutes.isEmpty()) {
71+
return routable;
72+
}
73+
74+
return new Routable() {
75+
@Override
76+
public HttpResponse execute(HttpRequest req) {
77+
return new BlockedRoutesFilter(blockedRoutes, routable).execute(req);
78+
}
79+
80+
@Override
81+
public boolean matches(HttpRequest req) {
82+
return routable.matches(req);
83+
}
84+
};
85+
}
86+
}

0 commit comments

Comments
 (0)