CVE-2026-79220
Overview
Background
- WebSocket opening handshake
- the HTTP-upgrade request the network service sends to establish a WebSocket, whose over-the-wire headers can be reported back to the initiating client.
- `OnStartOpeningHandshake` / `OnOpeningHandshakeStarted`
- the network-service path that packages the outgoing handshake request headers into a
mojom::WebSocketHandshakeRequestand delivers them to theWebSocketHandshakeClient. - Raw-headers access (`has_raw_headers_access_`)
- a per-client privilege indicating whether the client (e.g. a trusted browser-side consumer) is allowed to see sensitive raw request headers; renderers generally lack it.
- `HttpAuthCache`
- the network stack’s cache of credentials that it may automatically attach to a request as
Authorization/Proxy-Authorizationheaders without the initiating renderer’s knowledge.
Root Cause Analysis
When reporting the outgoing handshake request in websocket.cc, the code iterates the request headers and, for clients lacking has_raw_headers_access_, skipped only the Cookie header before copying each remaining header into the reported mojom::HttpHeader list. However, the network stack may attach Authorization and Proxy-Authorization headers pulled from HttpAuthCache that the initiating renderer never supplied and should not observe. Because the filter matched only net::HttpRequestHeaders::kCookie, these credential headers were reported back to a non-privileged client, disclosing cached credentials.
The fix widens the skip condition so that, when !has_raw_headers_access_, the loop also continues for headers case-insensitively equal to kAuthorization or kProxyAuthorization, matching the existing treatment of kCookie.
The added tests confirm a raw-headers client still sees Authorization while a renderer client sees Host but neither Authorization nor Proxy-Authorization (including in headers_text).
Cookie and omitted Authorization / Proxy-Authorization, which the network stack can silently add from HttpAuthCache; the fix extends the same continue-skip to those two headers.Attack Path
- Obtain cached credentials
Ensure the network service’s
HttpAuthCacheholds credentials (e.g. Basic auth) for a target origin, as modeled by the test’sAddBasicAuthCacheEntry(). - Initiate a WebSocket from a renderer
A renderer without raw-headers access opens a WebSocket to that origin, causing the network stack to auto-attach
Authorization(and/orProxy-Authorization) from the cache. - Receive the reported handshake
The network service reports the over-the-wire handshake request to the client via the opening-handshake callback; pre-patch it included the auto-attached credential headers because only
Cookiewas filtered. - Read leaked credentials
The renderer inspects the reported
mojom::WebSocketHandshakeRequestheaders and recovers credentials it was never entitled to see.
Impact Assessment
Authorization/Proxy-Authorization credentials that the network stack attached from the shared HttpAuthCache, an information disclosure in the Network service. Preconditions are that credentials for the target are cached and the client lacks has_raw_headers_access_; the advisory classifies this as an information leak and reports no exploited-in-the-wild status. No memory-safety or code-execution impact is implied.Changed Functions
| Function | Change | Notes |
|---|---|---|
WebSocketFactoryHandshakeTestservices/network/websocket_factory_unittest.cc |
modified | |
WebSocketFactoryHandshakeTestservices/network/websocket_factory_unittest.cc |
modified |
Files Changed
services/network/websocket.ccservices/network/websocket_factory_unittest.cc
Audit Directions
- Partial sensitive-header filtersSearch for header-sanitization loops that special-case only
Cookieand confirm they also coverAuthorization,Proxy-Authorization, and other credential-bearing headers. - Auto-attached vs. author-supplied headersReview any path that echoes request/response headers to a less-privileged consumer, checking for headers the network stack injects (auth cache, proxy auth, client certs) that the consumer never set.
- Raw-headers gating consistencyAudit all
has_raw_headers_access_branches across the network service to ensure the same set of sensitive headers is filtered uniformly, including in serializedheaders_textblobs.
Patch
From cc8758dc9c6b524b778d6e58a469903b3e60ed96 Mon Sep 17 00:00:00 2001
From: Martin Verde <thesalsa@google.com>
Date: Tue, 30 Jun 2026 21:24:29 -0700
Subject: [PATCH] Filter auth headers from WebSocket handshake reported to client
OnStartOpeningHandshake reports the over-the-wire handshake request back
to the WebSocketHandshakeClient. For clients without raw-headers access
it already drops Cookie; do the same for Authorization and
Proxy-Authorization, which the network stack may attach from
HttpAuthCache.
Add WebSocketFactoryHandshakeTest exercising the reported handshake
request against an EmbeddedTestServer with cached Basic credentials,
with and without raw-headers access.
Bug: 522823211
Change-Id: I5d84a5c719f4335a7bb65a4a7da8f0cd4e887e78
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8018463
Reviewed-by: Adam Rice <ricea@chromium.org>
Commit-Queue: Martin Pan-Verde <thesalsa@google.com>
Cr-Commit-Position: refs/heads/main@{#1655256}
---
diff --git a/services/network/websocket.cc b/services/network/websocket.cc
index 830cbe53..d16f3cc 100644
--- a/services/network/websocket.cc
+++ b/services/network/websocket.cc
@@ -488,8 +488,12 @@
net::HttpRequestHeaders::Iterator it(request->headers);
while (it.GetNext()) {
if (!impl_->has_raw_headers_access_ &&
- base::EqualsCaseInsensitiveASCII(it.name(),
- net::HttpRequestHeaders::kCookie)) {
+ (base::EqualsCaseInsensitiveASCII(it.name(),
+ net::HttpRequestHeaders::kCookie) ||
+ base::EqualsCaseInsensitiveASCII(
+ it.name(), net::HttpRequestHeaders::kAuthorization) ||
+ base::EqualsCaseInsensitiveASCII(
+ it.name(), net::HttpRequestHeaders::kProxyAuthorization))) {
continue;
}
mojom::HttpHeaderPtr header(mojom::HttpHeader::New());
diff --git a/services/network/websocket_factory_unittest.cc b/services/network/websocket_factory_unittest.cc
index f5844b3..3a40645 100644
--- a/services/network/websocket_factory_unittest.cc
+++ b/services/network/websocket_factory_unittest.cc
@@ -4,24 +4,38 @@
#include "services/network/websocket_factory.h"
+#include <algorithm>
#include <memory>
#include <string>
#include <vector>
#include "base/functional/callback.h"
+#include "base/strings/string_util.h"
#include "base/test/task_environment.h"
#include "base/test/test_future.h"
#include "mojo/public/cpp/bindings/pending_remote.h"
#include "mojo/public/cpp/bindings/receiver.h"
#include "mojo/public/cpp/system/functions.h"
+#include "net/base/auth.h"
#include "net/base/isolation_info.h"
+#include "net/http/http_auth.h"
+#include "net/http/http_auth_cache.h"
+#include "net/http/http_network_session.h"
+#include "net/http/http_request_headers.h"
+#include "net/http/http_transaction_factory.h"
#include "net/log/net_log.h"
#include "net/log/net_log_entry.h"
#include "net/log/net_log_event_type.h"
+#include "net/test/embedded_test_server/create_websocket_handler.h"
+#include "net/test/embedded_test_server/embedded_test_server.h"
+#include "net/test/embedded_test_server/install_default_websocket_handlers.h"
+#include "net/test/embedded_test_server/websocket_echo_handler.h"
#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
+#include "net/url_request/url_request_context.h"
#include "services/network/network_context.h"
#include "services/network/network_service.h"
#include "services/network/public/cpp/constants.h"
+#include "services/network/public/cpp/originating_process_id.h"
#include "services/network/public/mojom/network_context.mojom.h"
#include "services/network/public/mojom/websocket.mojom.h"
#include "services/network/test/fake_test_cert_verifier_params_factory.h"
@@ -61,7 +75,9 @@
: receiver_(this, std::move(receiver)) {}
~StubWebSocketHandshakeClient() override = default;
- void OnOpeningHandshakeStarted(mojom::WebSocketHandshakeRequestPtr) override {
+ void OnOpeningHandshakeStarted(
+ mojom::WebSocketHandshakeRequestPtr request) override {
+ handshake_request_future_.SetValue(std::move(request));
}
void OnConnectionEstablished(mojo::PendingRemote<mojom::WebSocket>,
@@ -72,8 +88,14 @@
void OnFailure(const std::string&, int32_t, int32_t) override {}
+ mojom::WebSocketHandshakeRequestPtr WaitForOpeningHandshake() {
+ return handshake_request_future_.Take();
+ }
+
private:
mojo::Receiver<mojom::WebSocketHandshakeClient> receiver_;
+ base::test::TestFuture<mojom::WebSocketHandshakeRequestPtr>
+ handshake_request_future_;
};
mojom::NetworkContextParamsPtr CreateNetworkContextParams() {
@@ -97,8 +119,11 @@
factory_ = std::make_unique<WebSocketFactory>(network_context_.get());
}
- void CreateWebSocket(const GURL& url,
- const std::vector<std::string>& requested_protocols) {
+ StubWebSocketHandshakeClient* CreateWebSocket(
+ const GURL& url,
+ const std::vector<std::string>& requested_protocols,
+ const OriginatingProcessId& process_id =
+ OriginatingProcessId::browser()) {
mojo::PendingRemote<mojom::WebSocketHandshakeClient> handshake_client;
// Keep all handshake clients alive to prevent WebSocket cleanup
stub_handshake_clients_.push_back(
@@ -109,13 +134,13 @@
// asynchronously.
factory_->CreateWebSocket(
url, requested_protocols, net::StorageAccessApiStatus::kNone,
- net::IsolationInfo(), {}, network::OriginatingProcessId::browser(),
- url::Origin::Create(url),
+ net::IsolationInfo(), {}, process_id, url::Origin::Create(url),
/*client_security_state=*/nullptr, /*options=*/0,
TRAFFIC_ANNOTATION_FOR_TESTS, std::move(handshake_client),
mojo::NullRemote(), mojo::NullRemote(), mojo::NullRemote(),
/*throttling_profile_id=*/std::nullopt,
network::GetTestNetworkRestrictionsId());
+ return stub_handshake_clients_.back().get();
}
protected:
@@ -196,6 +221,75 @@
}
}
+bool ContainsHeader(const mojom::WebSocketHandshakeRequestPtr& request,
+ std::string_view name) {
+ return std::ranges::any_of(request->headers, [&](const auto& header) {
+ return base::EqualsCaseInsensitiveASCII(header->name, name);
+ });
+}
+
+class WebSocketFactoryHandshakeTest : public WebSocketFactoryTest {
+ public:
+ WebSocketFactoryHandshakeTest() {
+ net::test_server::RegisterWebSocketHandler<
+ net::test_server::WebSocketEchoHandler>(&test_server_, "/echo");
+ EXPECT_TRUE(test_server_.Start());
+ }
+
+ void AddBasicAuthCacheEntry() {
+ net::HttpAuthCache* cache = network_context_->url_request_context()
+ ->http_transaction_factory()
+ ->GetSession()
+ ->http_auth_cache();
+ cache->Add(url::SchemeHostPort(test_server_.base_url()),
+ net::HttpAuth::AUTH_SERVER, "TestRealm",
+ net::HttpAuth::AUTH_SCHEME_BASIC, net::NetworkAnonymizationKey(),
+ "Basic realm=TestRealm",
+ net::AuthCredentials(u"user", u"password"), "/");
+ }
+
+ protected:
+ net::EmbeddedTestServer test_server_;
+};
+
+// Verifies that the handshake request reported to a client with raw headers
+// access includes credential headers attached by the network stack.
+TEST_F(WebSocketFactoryHandshakeTest,
+ OpeningHandshakeReportsAuthHeadersWithRawHeadersAccess) {
+ AddBasicAuthCacheEntry();
+
+ StubWebSocketHandshakeClient* client = CreateWebSocket(
+ net::test_server::GetWebSocketURL(test_server_, "/echo"), {});
+
+ mojom::WebSocketHandshakeRequestPtr request =
+ client->WaitForOpeningHandshake();
+ ASSERT_TRUE(request);
+ EXPECT_TRUE(ContainsHeader(request, net::HttpRequestHeaders::kAuthorization));
+}
+
+// Verifies that the handshake request reported to a client without raw
+// headers access omits credential headers attached by the network stack.
+TEST_F(WebSocketFactoryHandshakeTest,
+ OpeningHandshakeOmitsAuthHeadersWithoutRawHeadersAccess) {
+ AddBasicAuthCacheEntry();
+
+ StubWebSocketHandshakeClient* client =
+ CreateWebSocket(net::test_server::GetWebSocketURL(test_server_, "/echo"),
+ {}, OriginatingProcessId::renderer(RendererProcessId(1)));
+
+ mojom::WebSocketHandshakeRequestPtr request =
+ client->WaitForOpeningHandshake();
+ ASSERT_TRUE(request);
+ // The handshake should still report non-credential headers such as Host.
+ EXPECT_TRUE(ContainsHeader(request, net::HttpRequestHeaders::kHost));
+ EXPECT_FALSE(
+ ContainsHeader(request, net::HttpRequestHeaders::kAuthorization));
+ EXPECT_FALSE(
+ ContainsHeader(request, net::HttpRequestHeaders::kProxyAuthorization));
+ EXPECT_EQ(request->headers_text.find(net::HttpRequestHeaders::kAuthorization),
+ std::string::npos);
+}
+
} // namespace
} // namespace network
Regression Test / PoC
diff --git a/services/network/websocket_factory_unittest.cc b/services/network/websocket_factory_unittest.cc
index f5844b3..3a40645 100644
--- a/services/network/websocket_factory_unittest.cc
+++ b/services/network/websocket_factory_unittest.cc
@@ -4,24 +4,38 @@
#include "services/network/websocket_factory.h"
+#include <algorithm>
#include <memory>
#include <string>
#include <vector>
#include "base/functional/callback.h"
+#include "base/strings/string_util.h"
#include "base/test/task_environment.h"
#include "base/test/test_future.h"
#include "mojo/public/cpp/bindings/pending_remote.h"
#include "mojo/public/cpp/bindings/receiver.h"
#include "mojo/public/cpp/system/functions.h"
+#include "net/base/auth.h"
#include "net/base/isolation_info.h"
+#include "net/http/http_auth.h"
+#include "net/http/http_auth_cache.h"
+#include "net/http/http_network_session.h"
+#include "net/http/http_request_headers.h"
+#include "net/http/http_transaction_factory.h"
#include "net/log/net_log.h"
#include "net/log/net_log_entry.h"
#include "net/log/net_log_event_type.h"
+#include "net/test/embedded_test_server/create_websocket_handler.h"
+#include "net/test/embedded_test_server/embedded_test_server.h"
+#include "net/test/embedded_test_server/install_default_websocket_handlers.h"
+#include "net/test/embedded_test_server/websocket_echo_handler.h"
#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
+#include "net/url_request/url_request_context.h"
#include "services/network/network_context.h"
#include "services/network/network_service.h"
#include "services/network/public/cpp/constants.h"
+#include "services/network/public/cpp/originating_process_id.h"
#include "services/network/public/mojom/network_context.mojom.h"
#include "services/network/public/mojom/websocket.mojom.h"
#include "services/network/test/fake_test_cert_verifier_params_factory.h"
@@ -61,7 +75,9 @@
: receiver_(this, std::move(receiver)) {}
~StubWebSocketHandshakeClient() override = default;
- void OnOpeningHandshakeStarted(mojom::WebSocketHandshakeRequestPtr) override {
+ void OnOpeningHandshakeStarted(
+ mojom::WebSocketHandshakeRequestPtr request) override {
+ handshake_request_future_.SetValue(std::move(request));
}
void OnConnectionEstablished(mojo::PendingRemote<mojom::WebSocket>,
@@ -72,8 +88,14 @@
void OnFailure(const std::string&, int32_t, int32_t) override {}
+ mojom::WebSocketHandshakeRequestPtr WaitForOpeningHandshake() {
+ return handshake_request_future_.Take();
+ }
+
private:
mojo::Receiver<mojom::WebSocketHandshakeClient> receiver_;
+ base::test::TestFuture<mojom::WebSocketHandshakeRequestPtr>
+ handshake_request_future_;
};
mojom::NetworkContextParamsPtr CreateNetworkContextParams() {
@@ -97,8 +119,11 @@
factory_ = std::make_unique<WebSocketFactory>(network_context_.get());
}
- void CreateWebSocket(const GURL& url,
- const std::vector<std::string>& requested_protocols) {
+ StubWebSocketHandshakeClient* CreateWebSocket(
+ const GURL& url,
+ const std::vector<std::string>& requested_protocols,
+ const OriginatingProcessId& process_id =
+ OriginatingProcessId::browser()) {
mojo::PendingRemote<mojom::WebSocketHandshakeClient> handshake_client;
// Keep all handshake clients alive to prevent WebSocket cleanup
stub_handshake_clients_.push_back(
@@ -109,13 +134,13 @@
// asynchronously.
factory_->CreateWebSocket(
url, requested_protocols, net::StorageAccessApiStatus::kNone,
- net::IsolationInfo(), {}, network::OriginatingProcessId::browser(),
- url::Origin::Create(url),
+ net::IsolationInfo(), {}, process_id, url::Origin::Create(url),
/*client_security_state=*/nullptr, /*options=*/0,
TRAFFIC_ANNOTATION_FOR_TESTS, std::move(handshake_client),
mojo::NullRemote(), mojo::NullRemote(), mojo::NullRemote(),
/*throttling_profile_id=*/std::nullopt,
network::GetTestNetworkRestrictionsId());
+ return stub_handshake_clients_.back().get();
}
protected:
@@ -196,6 +221,75 @@
}
}
+bool ContainsHeader(const mojom::WebSocketHandshakeRequestPtr& request,
+ std::string_view name) {
+ return std::ranges::any_of(request->headers, [&](const auto& header) {
+ return base::EqualsCaseInsensitiveASCII(header->name, name);
+ });
+}
+
+class WebSocketFactoryHandshakeTest : public WebSocketFactoryTest {
+ public:
+ WebSocketFactoryHandshakeTest() {
+ net::test_server::RegisterWebSocketHandler<
+ net::test_server::WebSocketEchoHandler>(&test_server_, "/echo");
+ EXPECT_TRUE(test_server_.Start());
+ }
+
+ void AddBasicAuthCacheEntry() {
+ net::HttpAuthCache* cache = network_context_->url_request_context()
+ ->http_transaction_factory()
+ ->GetSession()
+ ->http_auth_cache();
+ cache->Add(url::SchemeHostPort(test_server_.base_url()),
+ net::HttpAuth::AUTH_SERVER, "TestRealm",
+ net::HttpAuth::AUTH_SCHEME_BASIC, net::NetworkAnonymizationKey(),
+ "Basic realm=TestRealm",
+ net::AuthCredentials(u"user", u"password"), "/");
+ }
+
+ protected:
+ net::EmbeddedTestServer test_server_;
+};
+
+// Verifies that the handshake request reported to a client with raw headers
+// access includes credential headers attached by the network stack.
+TEST_F(WebSocketFactoryHandshakeTest,
+ OpeningHandshakeReportsAuthHeadersWithRawHeadersAccess) {
+ AddBasicAuthCacheEntry();
+
+ StubWebSocketHandshakeClient* client = CreateWebSocket(
+ net::test_server::GetWebSocketURL(test_server_, "/echo"), {});
+
+ mojom::WebSocketHandshakeRequestPtr request =
+ client->WaitForOpeningHandshake();
+ ASSERT_TRUE(request);
+ EXPECT_TRUE(ContainsHeader(request, net::HttpRequestHeaders::kAuthorization));
+}
+
+// Verifies that the handshake request reported to a client without raw
+// headers access omits credential headers attached by the network stack.
+TEST_F(WebSocketFactoryHandshakeTest,
+ OpeningHandshakeOmitsAuthHeadersWithoutRawHeadersAccess) {
+ AddBasicAuthCacheEntry();
+
+ StubWebSocketHandshakeClient* client =
+ CreateWebSocket(net::test_server::GetWebSocketURL(test_server_, "/echo"),
+ {}, OriginatingProcessId::renderer(RendererProcessId(1)));
+
+ mojom::WebSocketHandshakeRequestPtr request =
+ client->WaitForOpeningHandshake();
+ ASSERT_TRUE(request);
+ // The handshake should still report non-credential headers such as Host.
+ EXPECT_TRUE(ContainsHeader(request, net::HttpRequestHeaders::kHost));
+ EXPECT_FALSE(
+ ContainsHeader(request, net::HttpRequestHeaders::kAuthorization));
+ EXPECT_FALSE(
+ ContainsHeader(request, net::HttpRequestHeaders::kProxyAuthorization));
+ EXPECT_EQ(request->headers_text.find(net::HttpRequestHeaders::kAuthorization),
+ std::string::npos);
+}
+
} // namespace
} // namespace network
Original Bug Report
Information Leak: WebSocket handshake leaks HTTP Auth credentials to renderer
Flapjack, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The Network Service fails to redact sensitive HTTP authentication headers when reporting WebSocket handshake details back to the renderer process. A compromised renderer can exploit this to steal cached user credentials by initiating a WebSocket connection to an authenticated origin or proxy.
Affected files:
services/network/websocket.cc
Estimated timestamp from git blame: 2018-11-30
Vulnerability Description
A potential high-severity information leak exists in Chromium’s Network Service. When a WebSocket connection is initiated, the underlying HTTP transaction (net::HttpNetworkTransaction) automatically appends cached credentials (e.g., for Basic or Digest authentication) to the outgoing request via Authorization and Proxy-Authorization headers if the user has previously authenticated to the target server or proxy.
To support diagnostic features, the Network Service reports the over-the-wire headers back to the renderer process through the mojom::WebSocketHandshakeClient::OnOpeningHandshakeStarted Mojo interface. The code responsible for preparing this message is WebSocketEventHandler::OnStartOpeningHandshake within services/network/websocket.cc.
While the implementation explicitly filters out the Cookie header for renderers that lack raw header access (impl_->has_raw_headers_access_ is false), it fails to apply this filtering to Authorization or Proxy-Authorization headers. Consequently, sensitive cached credentials are inadvertently leaked across the process boundary back to the unprivileged renderer.
Potential Exploitation Steps
An attacker who has achieved code execution within a sandboxed renderer process (e.g., via a V8 or Blink vulnerability) could potentially exploit this to steal user credentials:
- The attacker’s compromised renderer initiates a new WebSocket connection via the
network::mojom::NetworkContext::CreateWebSocketMojo interface, targeting a URL where the user has an active HTTP Auth session (or a request that traverses an authenticated proxy). - The Network Service sets up the connection. Because valid credentials exist in
net::HttpAuthCache,net::HttpNetworkTransaction::BuildRequestHeadersappends theAuthorizationorProxy-Authorizationheader to the request. - The
net::WebSocketBasicHandshakeStreamcopies these enriched headers into aWebSocketHandshakeRequestInfoobject and triggers theOnStartOpeningHandshakeevent. - In
services/network/websocket.cc, the sanitization loop fails to strip the authentication headers because it only checks fornet::HttpRequestHeaders::kCookie. - The Network Service sends the unredacted headers back to the renderer via the
OnOpeningHandshakeStartedIPC. - The attacker’s custom
WebSocketHandshakeClientreceives the IPC and extracts the base64-encoded or plaintext credentials from theAuthorizationheader.
Note: Our tooling agent cannot run code, so these are potential steps derived from static analysis of the codebase.
Impact
This bypasses the renderer/network privilege separation intended to protect credentials. While server authentication credentials (AUTH_SERVER) are partitioned by NetworkAnonymizationKey, proxy credentials (AUTH_PROXY) are not partitioned (they use an empty NAK). Thus, a single compromised renderer could potentially extract global proxy credentials, elevating the impact of the leak.
Recommended Fix
The filtering logic in services/network/websocket.cc within WebSocketEventHandler::OnStartOpeningHandshake should be updated to strip net::HttpRequestHeaders::kAuthorization and net::HttpRequestHeaders::kProxyAuthorization (in addition to kCookie) when impl_->has_raw_headers_access_ is false.
Evaluated with Chrome root at commit: 2155cb00003ec35716a76ed3246eae995f87b7ff
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.