CVE-2026-5901
Overview
Background
- `DevToolsAgentHostClient::MayAttachToURL`
- the per-client policy hook a DevTools/CDP client (for example an extension using the debugger API, or a client restricted by browser policy) uses to say which URLs it may inspect; the regression test drives it with
SetNotAttachableHosts({"b.test"}). - CDP cookie commands
Network.getCookies/getAllCookies,Network.setCookie/setCookies,Network.deleteCookies,Network.clearBrowserCookiesand theStorage.*Cookiesequivalents, all implemented innetwork_handler.ccandstorage_handler.ccon top of the browser-processCookieManager.- Read-side filtering
- before this patch, cookie reads were already filtered by calling
MayAttachToURLonhttps://<domain>andhttp://<domain>for each cookie; that inline logic is what the patch extracts intoNetworkHandler::CanAccessCookie. - `CookieDeletionFilter::New()`
- an empty deletion filter, which deletes every cookie in the storage partition in one call.
Root Cause Analysis
The DevTools cookie handlers enforced the client’s attach policy only on the read path: the removed lines in the cookie-retrieval code of network_handler.cc and storage_handler.cc filtered results with client_->MayAttachToURL(...), but the write and delete paths had no equivalent check. Network.setCookie passed the cookie from MakeCookieFromProtocolValues directly to the cookie manager, the static NetworkHandler::SetCookies (shared by Network.setCookies and Storage.setCookies) did the same for each entry, DeleteFilteredCookies deleted every cookie matching name, domain and path, and both Network.clearBrowserCookies and Storage.clearCookies called DeleteCookies with an empty CookieDeletionFilter, wiping every cookie in the partition. A client that policy barred from a host could therefore still create, overwrite or delete that host’s cookies, which breaks the rule that a restricted client can only affect the origins it is allowed to attach to.
The fix adds a single predicate, NetworkHandler::CanAccessCookie, which requires MayAttachToURL to pass for both the https and http forms of cookie.DomainWithoutDot().
The patch applies it to every mutation: setCookie returns Response::ServerError("Permission denied") before the cookie is set, SetCookies fails the whole batch (callback.Run(false)) on the first inaccessible cookie before anything is written, and DeleteFilteredCookies removes inaccessible cookies from the list with std::erase_if before issuing any DeleteCanonicalCookie. Clearing goes through the new NetworkHandler::ClearCookies, which runs GetAllCookies and then the same filtered per-cookie deletion with no name, domain or path filter (filter_by_partition_key=false), replacing the blanket DeleteCookies.
CanAccessCookie and runs it on every cookie before any mutation reaches the CookieManager.Attack Path
- Restricted CDP client attaches
a DevTools client whose
MayAttachToURLrejects some host (b.testin the regression test) attaches to a target it is allowed to debug. - Write to a forbidden host
it sends
Network.setCookieorNetwork.setCookies(orStorage.setCookies) withdomainset to the forbidden host. Before the patch, the cookie was built byMakeCookieFromProtocolValuesand stored without any policy check. - Delete or clear forbidden cookies
it sends
Network.deleteCookiesnaming the forbidden domain, orNetwork.clearBrowserCookies/Storage.clearCookies. Before the patch, these deleted matching cookies, or all cookies through an emptyCookieDeletionFilter, regardless of host. - Effect on the protected site
the forbidden site’s session state is planted, overwritten or erased (for example session fixation or forced logout,
(inferred)), even though the client could not read those cookies. The test shows that after the fixb.test’sfoo=barsurvivesclearBrowserCookieswhile thea.testcookie is removed.
Impact Assessment
(inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
forcontent/browser/devtools/protocol/network_handler.cc |
modified | |
ifcontent/browser/devtools/protocol/network_handler.cc |
modified | |
erase_ifcontent/browser/devtools/protocol/network_handler.cc |
modified | |
forcontent/browser/devtools/protocol/storage_handler.cc |
modified | |
ifcontent/browser/devtools/protocol/storage_handler.cc |
modified |
Files Changed
content/browser/devtools/protocol/devtools_protocol_browsertest.cccontent/browser/devtools/protocol/network_handler.cccontent/browser/devtools/protocol/network_handler.hcontent/browser/devtools/protocol/storage_handler.ccthird_party/blink/web_tests/http/tests/inspector-protocol/storage/cookies-expected.txt
Audit Directions
- Read-only enforcement of per-client URL policyfor every CDP domain that filters reads with
MayAttachToURL, check whether the matching set, delete, clear or override commands (for exampleStorage.clearDataForOrigin,Storage.setInterestGroup*,DOMStorage.*,IndexedDB.*,CacheStorage.*) apply the same check. - Blanket filters versus per-item checkslook for
DeleteCookies/ClearData-style calls with empty filters that act on a whole partition for a restricted client. Note that the newGetAllCookies→ per-cookie delete flow is not atomic, so check that callers do not depend on all-or-nothing clearing. - Domain-to-URL mapping in `CanAccessCookie`the check uses
cookie.DomainWithoutDot(). Review how host-only cookies, IP-literal domains, partitioned cookies whose top-level site differs from the cookie domain, andMayAttachToURLimplementations that check by origin pattern all interact with this mapping.
Patch
diff --git a/content/browser/devtools/protocol/network_handler.cc b/content/browser/devtools/protocol/network_handler.cc
index ca70338f..673fdb0 100644
--- a/content/browser/devtools/protocol/network_handler.cc
+++ b/content/browser/devtools/protocol/network_handler.cc
@@ -15,6 +15,7 @@
#include "base/barrier_closure.h"
#include "base/base64.h"
+#include "base/check_deref.h"
#include "base/command_line.h"
#include "base/containers/queue.h"
#include "base/containers/span.h"
@@ -312,39 +313,49 @@
namespace {
std::vector<net::CanonicalCookie> FilterCookies(
const std::vector<net::CanonicalCookie>& cookies,
- const std::string& name,
- const std::string& normalized_domain,
- const std::string& path,
- std::unique_ptr<Network::CookiePartitionKey> partition_key) {
+ const std::optional<std::string>& name,
+ const std::optional<std::string>& normalized_domain,
+ const std::optional<std::string>& path,
+ const Network::CookiePartitionKey* partition_key,
+ bool filter_by_partition_key) {
std::vector<net::CanonicalCookie> result;
for (const auto& cookie : cookies) {
- if (cookie.Name() != name)
+ if (name.has_value() && cookie.Name() != name.value()) {
continue;
- if (cookie.Domain() != normalized_domain)
+ }
+ if (normalized_domain.has_value() &&
+ cookie.Domain() != normalized_domain.value()) {
continue;
- if (!path.empty() && cookie.Path() != path)
- continue;
-
- if (!!cookie.PartitionKey() != !!partition_key) {
+ }
+ if (path.has_value() && !path.value().empty() &&
+ cookie.Path() != path.value()) {
continue;
}
- if (cookie.PartitionKey().has_value()) {
- base::expected<net::CookiePartitionKey::SerializedCookiePartitionKey,
- std::string>
- serialized_result =
- net::CookiePartitionKey::Serialize(cookie.PartitionKey());
-
- if (!serialized_result.has_value() ||
- (serialized_result->TopLevelSite() !=
- partition_key->GetTopLevelSite())) {
+ if (filter_by_partition_key) {
+ if (!!cookie.PartitionKey() != !!partition_key) {
continue;
}
- if (serialized_result->has_cross_site_ancestor() !=
- partition_key->GetHasCrossSiteAncestor()) {
- continue;
+ if (cookie.PartitionKey().has_value()) {
+ base::expected<net::CookiePartitionKey::SerializedCookiePartitionKey,
+ std::string>
+ serialized_result =
+ net::CookiePartitionKey::Serialize(cookie.PartitionKey());
+
+ if (!serialized_result.has_value() ||
+ (serialized_result->TopLevelSite() !=
+ const_cast<Network::CookiePartitionKey*>(partition_key)
+ ->GetTopLevelSite())) {
+ continue;
+ }
+
+ if (serialized_result->has_cross_site_ancestor() !=
+ const_cast<Network::CookiePartitionKey*>(partition_key)
+ ->GetHasCrossSiteAncestor()) {
+ continue;
+ }
}
}
@@ -354,20 +365,26 @@
return result;
}
-void DeleteFilteredCookies(
- network::mojom::CookieManager* cookie_manager,
- const std::string& name,
- const std::string& normalized_domain,
- const std::string& path,
- std::unique_ptr<Network::CookiePartitionKey> partition_key,
- std::unique_ptr<DeleteCookiesCallback> callback,
- const std::vector<net::CanonicalCookie>& cookies) {
- std::vector<net::CanonicalCookie> filtered_list = FilterCookies(
- cookies, name, normalized_domain, path, std::move(partition_key));
+void DeleteFilteredCookies(network::mojom::CookieManager* cookie_manager,
+ const std::optional<std::string>& name,
+ const std::optional<std::string>& normalized_domain,
+ const std::optional<std::string>& path,
+ const Network::CookiePartitionKey* partition_key,
+ bool filter_by_partition_key,
+ DevToolsAgentHostClient& client,
+ bool is_webui,
+ base::OnceClosure success_callback,
+ const std::vector<net::CanonicalCookie>& cookies) {
+ std::vector<net::CanonicalCookie> filtered_list =
+ FilterCookies(cookies, name, normalized_domain, path, partition_key,
+ filter_by_partition_key);
- base::RepeatingClosure barrier_closure = base::BarrierClosure(
- filtered_list.size(),
- base::BindOnce(&DeleteCookiesCallback::sendSuccess, std::move(callback)));
+ std::erase_if(filtered_list, [&](const net::CanonicalCookie& cookie) {
+ return !NetworkHandler::CanAccessCookie(client, is_webui, cookie);
+ });
+
+ base::RepeatingClosure barrier_closure =
+ base::BarrierClosure(filtered_list.size(), std::move(success_callback));
for (auto& cookie : filtered_list) {
cookie_manager->DeleteCanonicalCookie(
@@ -402,20 +419,19 @@
} // namespace
std::variant<std::unique_ptr<net::CanonicalCookie>, Response>
-MakeCookieFromProtocolValues(
- const std::string& name,
- const std::string& value,
- const std::string& url_spec,
- const std::string& domain,
- const std::string& path,
- bool secure,
- bool http_only,
- const std::string& same_site,
- double expires,
- const std::string& priority,
- const std::optional<std::string>& source_scheme,
- const std::optional<int>& source_port,
- std::unique_ptr<Network::CookiePartitionKey>& partition_key) {
+MakeCookieFromProtocolValues(const std::string& name,
+ const std::string& value,
+ const std::string& url_spec,
+ const std::string& domain,
+ const std::string& path,
+ bool secure,
+ bool http_only,
+ const std::string& same_site,
+ double expires,
+ const std::string& priority,
+ const std::optional<std::string>& source_scheme,
+ const std::optional<int>& source_port,
+ const Network::CookiePartitionKey* partition_key) {
std::string normalized_domain = domain;
if (url_spec.empty() && domain.empty()) {
@@ -472,12 +488,16 @@
cp = net::CookiePriority::COOKIE_PRIORITY_LOW;
std::optional<net::CookiePartitionKey> cookie_partition_key;
- if (partition_key && !partition_key->GetTopLevelSite().empty()) {
+ if (partition_key && !const_cast<Network::CookiePartitionKey*>(partition_key)
+ ->GetTopLevelSite()
+ .empty()) {
base::expected<net::CookiePartitionKey, std::string>
deserialized_partition_key =
net::CookiePartitionKey::FromUntrustedInput(
- partition_key->GetTopLevelSite(),
- partition_key->GetHasCrossSiteAncestor());
+ const_cast<Network::CookiePartitionKey*>(partition_key)
+ ->GetTopLevelSite(),
+ const_cast<Network::CookiePartitionKey*>(partition_key)
+ ->GetHasCrossSiteAncestor());
if (!deserialized_partition_key.has_value()) {
return Response::InvalidParams(
"Deserializing cookie partition key failed");
@@ -2383,13 +2403,40 @@
return;
}
- storage_partition_->GetCookieManagerForBrowserProcess()->DeleteCookies(
- network::mojom::CookieDeletionFilter::New(),
- base::BindOnce([](std::unique_ptr<ClearBrowserCookiesCallback> callback,
- uint32_t) { callback->sendSuccess(); },
+ NetworkHandler::ClearCookies(
+ storage_partition_, CHECK_DEREF(client_.get()), host_ && host_->web_ui(),
+ base::BindOnce(&ClearBrowserCookiesCallback::sendSuccess,
std::move(callback)));
}
+// static
+void NetworkHandler::ClearCookies(StoragePartition* storage_partition,
+ DevToolsAgentHostClient& client,
+ bool is_webui,
+ base::OnceClosure callback) {
+ auto* cookie_manager = storage_partition->GetCookieManagerForBrowserProcess();
+ cookie_manager->GetAllCookies(
+ base::BindOnce(&DeleteFilteredCookies, base::Unretained(cookie_manager),
+ /*name=*/std::nullopt, /*normalized_domain=*/std::nullopt,
+ /*path=*/std::nullopt, /*partition_key=*/nullptr,
+ /*filter_by_partition_key=*/false, std::ref(client),
+ is_webui, std::move(callback)));
+}
+
+// static
+bool NetworkHandler::CanAccessCookie(DevToolsAgentHostClient& client,
+ bool is_webui,
+ const net::CanonicalCookie& cookie) {
+ return client.MayAttachToURL(GURL(base::StrCat({url::kHttpsScheme,
+ url::kStandardSchemeSeparator,
+ cookie.DomainWithoutDot()})),
+ is_webui) &&
+ client.MayAttachToURL(
+ GURL(base::StrCat({url::kHttpScheme, url::kStandardSchemeSeparator,
+ cookie.DomainWithoutDot()})),
+ is_webui);
+}
+
void NetworkHandler::GetCookies(std::unique_ptr<Array<String>> protocol_urls,
std::unique_ptr<GetCookiesCallback> callback) {
if (!host_ || !storage_partition_) {
@@ -2426,14 +2473,8 @@
bool is_webui = host_ && host_->web_ui();
std::vector<net::CanonicalCookie> filtered_cookies;
for (const auto& cookie : cookies) {
- if (client_->MayAttachToURL(
- GURL(base::StrCat({url::kHttpsScheme, url::kStandardSchemeSeparator,
- cookie.DomainWithoutDot()})),
- is_webui) &&
- client_->MayAttachToURL(
- GURL(base::StrCat({url::kHttpScheme, url::kStandardSchemeSeparator,
- cookie.DomainWithoutDot()})),
- is_webui)) {
+ if (NetworkHandler::CanAccessCookie(CHECK_DEREF(client_.get()), is_webui,
+ cookie)) {
filtered_cookies.emplace_back(std::move(cookie));
}
}
@@ -2464,7 +2505,7 @@
name, value, url.value_or(""), domain.value_or(""), path.value_or(""),
secure.value_or(false), http_only.value_or(false), same_site.value_or(""),
expires.value_or(-1), priority.value_or(""), source_scheme, source_port,
- partition_key);
+ partition_key.get());
if (std::holds_alternative<Response>(cookie_or_error)) {
callback->sendFailure(std::get<Response>(std::move(cookie_or_error)));
@@ -2474,6 +2515,12 @@
std::get<std::unique_ptr<net::CanonicalCookie>>(
std::move(cookie_or_error));
+ if (!NetworkHandler::CanAccessCookie(CHECK_DEREF(client_.get()),
+ host_ && host_->web_ui(), *cookie)) {
+ callback->sendFailure(Response::ServerError("Permission denied"));
+ return;
+ }
+
net::CookieOptions options;
// Permit it to set a SameSite cookie if it wants to.
options.set_same_site_cookie_context(
@@ -2491,6 +2538,8 @@
void NetworkHandler::SetCookies(
StoragePartition* storage_partition,
std::unique_ptr<protocol::Array<Network::CookieParam>> cookies,
+ DevToolsAgentHostClient& client,
+ bool is_webui,
base::OnceCallback<void(bool)> callback) {
std::vector<std::unique_ptr<net::CanonicalCookie>> net_cookies;
for (const std::unique_ptr<Network::CookieParam>& cookie : *cookies) {
@@ -2523,15 +2572,20 @@
cookie->GetDomain(""), cookie->GetPath(""), cookie->GetSecure(false),
cookie->GetHttpOnly(false), cookie->GetSameSite(""),
cookie->GetExpires(-1), cookie->GetPriority(""), source_scheme,
- source_port, partition_key);
+ source_port, partition_key.get());
if (std::holds_alternative<Response>(net_cookie_or_error)) {
// TODO: Investiage whether we can report the error as a protocol error
// (this might be a breaking CDP change).
std::move(callback).Run(false);
return;
}
- net_cookies.push_back(std::get<std::unique_ptr<net::CanonicalCookie>>(
- std::move(net_cookie_or_error)));
+ auto net_cookie = std::get<std::unique_ptr<net::CanonicalCookie>>(
+ std::move(net_cookie_or_error));
+ if (!NetworkHandler::CanAccessCookie(client, is_webui, *net_cookie)) {
+ std::move(callback).Run(false);
+ return;
+ }
+ net_cookies.push_back(std::move(net_cookie));
}
base::RepeatingClosure barrier_closure = base::BarrierClosure(
@@ -2560,9 +2614,11 @@
callback->sendFailure(Response::InternalError());
return;
}
+ CHECK(host_);
NetworkHandler::SetCookies(
- storage_partition_, std::move(cookies),
+ storage_partition_, std::move(cookies), CHECK_DEREF(client_.get()),
+ host_->web_ui(),
base::BindOnce(
[](std::unique_ptr<SetCookiesCallback> callback, bool success) {
if (success) {
@@ -2605,10 +2661,14 @@
auto* cookie_manager =
storage_partition_->GetCookieManagerForBrowserProcess();
- cookie_manager->GetAllCookies(
- base::BindOnce(&DeleteFilteredCookies, base::Unretained(cookie_manager),
- name, normalized_domain, path.value_or(""),
- std::move(partition_key), std::move(callback)));
+ cookie_manager->GetAllCookies(base::BindOnce(
+ &DeleteFilteredCookies, base::Unretained(cookie_manager), name,
+ normalized_domain, path.value_or(""),
+ base::Owned(partition_key.release()),
+ /*filter_by_partition_key=*/true, std::ref(CHECK_DEREF(client_.get())),
+ host_ && host_->web_ui(),
+ base::BindOnce(&DeleteCookiesCallback::sendSuccess,
+ std::move(callback))));
}
Response NetworkHandler::SetExtraHTTPHeaders(
diff --git a/content/browser/devtools/protocol/network_handler.h b/content/browser/devtools/protocol/network_handler.h
index 24dce87..8e7bcfb3 100644
--- a/content/browser/devtools/protocol/network_handler.h
+++ b/content/browser/devtools/protocol/network_handler.h
@@ -116,8 +116,19 @@
static void SetCookies(
StoragePartition* storage_partition,
std::unique_ptr<protocol::Array<Network::CookieParam>> cookies,
+ DevToolsAgentHostClient& client,
+ bool is_webui,
base::OnceCallback<void(bool)> callback);
+ static void ClearCookies(StoragePartition* storage_partition,
+ DevToolsAgentHostClient& client,
+ bool is_webui,
+ base::OnceClosure callback);
+
+ static bool CanAccessCookie(DevToolsAgentHostClient& client,
+ bool is_webui,
+ const net::CanonicalCookie& cookie);
+
void Wire(UberDispatcher* dispatcher) override;
void SetRenderer(int render_process_id,
RenderFrameHostImpl* frame_host) override;
diff --git a/content/browser/devtools/protocol/storage_handler.cc b/content/browser/devtools/protocol/storage_handler.cc
index b33e82dd..761a1e3 100644
--- a/content/browser/devtools/protocol/storage_handler.cc
+++ b/content/browser/devtools/protocol/storage_handler.cc
@@ -15,6 +15,7 @@
#include <vector>
#include "base/barrier_closure.h"
+#include "base/check_deref.h"
#include "base/functional/bind.h"
#include "base/notreached.h"
#include "base/scoped_observation.h"
@@ -491,14 +492,8 @@
bool is_webui = frame_host_ && frame_host_->web_ui();
std::vector<net::CanonicalCookie> filtered_cookies;
for (const auto& cookie : cookies) {
- if (client_->MayAttachToURL(
- GURL(base::StrCat({url::kHttpsScheme, url::kStandardSchemeSeparator,
- cookie.DomainWithoutDot()})),
- is_webui) &&
- client_->MayAttachToURL(
- GURL(base::StrCat({url::kHttpScheme, url::kStandardSchemeSeparator,
- cookie.DomainWithoutDot()})),
- is_webui)) {
+ if (NetworkHandler::CanAccessCookie(CHECK_DEREF(client_.get()), is_webui,
+ cookie)) {
filtered_cookies.emplace_back(std::move(cookie));
}
}
@@ -518,7 +513,8 @@
}
NetworkHandler::SetCookies(
- storage_partition, std::move(cookies),
+ storage_partition, std::move(cookies), CHECK_DEREF(client_.get()),
+ frame_host_ && frame_host_->web_ui(),
base::BindOnce(
[](std::unique_ptr<SetCookiesCallback> callback, bool success) {
if (success) {
@@ -542,11 +538,10 @@
return;
}
- storage_partition->GetCookieManagerForBrowserProcess()->DeleteCookies(
- network::mojom::CookieDeletionFilter::New(),
- base::BindOnce([](std::unique_ptr<ClearCookiesCallback> callback,
- uint32_t) { callback->sendSuccess(); },
- std::move(callback)));
+ NetworkHandler::ClearCookies(
... (file diff truncated)
Regression Test / PoC
diff --git a/content/browser/devtools/protocol/devtools_protocol_browsertest.cc b/content/browser/devtools/protocol/devtools_protocol_browsertest.cc
index 9e29f4d0..ac7cb63d 100644
--- a/content/browser/devtools/protocol/devtools_protocol_browsertest.cc
+++ b/content/browser/devtools/protocol/devtools_protocol_browsertest.cc
@@ -2764,6 +2764,76 @@
EXPECT_EQ(2u, found);
}
+IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest, CookiePermissions) {
+ SetNotAttachableHosts({"b.test"});
+ content::SetupCrossSiteRedirector(embedded_test_server());
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ std::string cookies_to_set = "/set-cookie?foo=bar";
+ GURL url = embedded_test_server()->GetURL("b.test", cookies_to_set);
+ EXPECT_TRUE(NavigateToURL(shell(), url));
+ url = embedded_test_server()->GetURL("a.test", cookies_to_set);
+ EXPECT_TRUE(NavigateToURL(shell(), url));
+
+ Attach();
+
+ // Try to set a cookie on b.test via protocol.
+ base::DictValue set_params;
+ set_params.Set("name", "proto_cookie");
+ set_params.Set("value", "proto_val");
+ set_params.Set("domain", "b.test");
+ set_params.Set("path", "/");
+ SendCommandSync("Network.setCookie", std::move(set_params));
+ EXPECT_THAT(
+ error()->FindInt("code"),
+ testing::Optional(static_cast<int>(crdtp::DispatchCode::SERVER_ERROR)));
+ EXPECT_EQ(*error()->FindString("message"), "Permission denied");
+
+ // Try to set cookies on b.test via protocol.
+ base::DictValue set_cookies_params;
+ base::ListValue cookies_list;
+ base::DictValue cookie;
+ cookie.Set("name", "proto_cookie_2");
+ cookie.Set("value", "val");
+ cookie.Set("domain", "b.test");
+ cookie.Set("path", "/");
+ cookies_list.Append(std::move(cookie));
+ set_cookies_params.Set("cookies", std::move(cookies_list));
+ SendCommandSync("Network.setCookies", std::move(set_cookies_params));
+ EXPECT_THAT(
+ error()->FindInt("code"),
+ testing::Optional(static_cast<int>(crdtp::DispatchCode::INVALID_PARAMS)));
+ EXPECT_EQ(*error()->FindString("message"), "Invalid cookie fields");
+
+ // Try to delete cookie on b.test via protocol.
+ base::DictValue del_params;
+ del_params.Set("name", "foo");
+ del_params.Set("domain", "b.test");
+ SendCommandSync("Network.deleteCookies", std::move(del_params));
+ EXPECT_FALSE(error());
+
+ // Try to clear browser cookies.
+ SendCommandSync("Network.clearBrowserCookies");
+ EXPECT_FALSE(error());
+
+ // Verify a.test cookie is gone.
+ const base::ListValue* cookies =
+ SendCommandSync("Network.getAllCookies")->FindList("cookies");
+ ASSERT_TRUE(cookies);
+ EXPECT_EQ(0u, cookies->size());
+
+ Detach();
+
+ // Verify b.test cookie is still there.
+ GURL url_b_echo =
+ embedded_test_server()->GetURL("b.test", "/echoheader?Cookie");
+ EXPECT_TRUE(NavigateToURL(shell(), url_b_echo));
+ std::string content =
+ EvalJs(shell()->web_contents(), "document.body.innerText")
+ .ExtractString();
+ EXPECT_THAT(content, testing::HasSubstr("foo=bar"));
+}
+
IN_PROC_BROWSER_TEST_F(DevToolsProtocolTest,
ReturnsCookiesOnlyForAttachableUrls) {
SetNotAttachableHosts({"b.test"});
diff --git a/third_party/blink/web_tests/http/tests/inspector-protocol/storage/cookies-expected.txt b/third_party/blink/web_tests/http/tests/inspector-protocol/storage/cookies-expected.txt
index 30ccb31..21690bf 100644
--- a/third_party/blink/web_tests/http/tests/inspector-protocol/storage/cookies-expected.txt
+++ b/third_party/blink/web_tests/http/tests/inspector-protocol/storage/cookies-expected.txt
@@ -22,7 +22,7 @@
{
error : {
code : -32602
- message : Failed to find browser context for id invalid
+ message : browserContextId is only allowed for Browser target
}
id : <number>
sessionId : <string>
Original Bug Report
DevTools cookie write/delete APIs allow extensions to modify cookies for enterprise policy-blocked sites despite runtime_blocked_hosts restrictions
Report description
DevTools cookie write/delete APIs allow extensions to modify cookies for enterprise policy-blocked sites despite runtime_blocked_hosts restrictions
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
Chromium enforces enterprise host restrictions for extensions via runtime_blocked_hosts in the ExtensionSettings policy. On Windows this can be configured, for example, as:
Windows Registry Editor Version 5.00
[HKEY_CURRENT_USER\Software\Policies\Google\Chrome\ExtensionSettings]
"*"="{\"runtime_blocked_hosts\":[\"https://www.google.com/*\"]}"
This is intended to ensure that extensions cannot interact with https://www.google.com/*, even if they have powerful permissions like debugger.
When an extension uses the chrome.debugger API, the DevTools client for extensions (ExtensionDevToolsClientHost) consults ExtensionMayAttachToURL / ExtensionMayAttachToURLOrInnerURL, which explicitly treats policy-blocked hosts as forbidden:
// chrome/browser/extensions/api/debugger/debugger_api.cc
bool ExtensionMayAttachToURL(const Extension& extension,
Profile* extension_profile,
const GURL& url,
std::string* error) {
...
const GURL& url_for_restriction_check =
url.SchemeIsBlob() ? url::Origin::Create(url).GetURL() : url;
if (extension.permissions_data()->IsRestrictedUrl(url_for_restriction_check,
error)) {
return false;
}
// Policy blocked hosts supersede the `debugger` permission.
if (extension.permissions_data()->IsPolicyBlockedHost(url) ||
extension.permissions_data()->IsPolicyBlockedHost(
url_for_restriction_check)) {
*error = kRestrictedError;
return false;
}
...
}
This correctly prevents an extension from attaching DevTools directly to a tab whose URL matches runtime_blocked_hosts (such as https://www.google.com).
However, once an extension is attached to any allowed tab, it can still use DevTools Protocol (CDP) commands that operate on the global cookie jar and supply arbitrary target URLs or domains. For cookie read operations, the implementation has been updated to use the DevTools client’s MayAttachToURL check when deciding which hosts are accessible:
// content/browser/devtools/protocol/network_handler.cc
void NetworkHandler::GetCookies(
std::unique_ptr<Array<String>> protocol_urls,
std::unique_ptr<GetCookiesCallback> callback) {
...
std::vector<GURL> urls = ComputeCookieURLs(host_, protocol_urls);
bool is_webui = host_ && host_->web_ui();
std::erase_if(urls, [=, this](const GURL& url) {
return !client_->MayAttachToURL(url, is_webui);
});
...
}
and similarly for GetAllCookies, which filters the results using synthesized HTTP/HTTPS URLs per cookie domain. This ensures that enterprise host restrictions are applied to cookie enumeration.
In contrast, cookie write/delete/clear DevTools handlers do not perform any MayAttachToURL or policy-based checks on the target url or domain. They act only on the presence of a StoragePartition and the syntactic validity of the cookie parameters:
// content/browser/devtools/protocol/network_handler.cc
void NetworkHandler::SetCookie(
const std::string& name,
const std::string& value,
std::optional<std::string> url,
std::optional<std::string> domain,
std::optional<std::string> path,
std::optional<bool> secure,
std::optional<bool> http_only,
std::optional<std::string> same_site,
std::optional<double> expires,
std::optional<std::string> priority,
std::optional<bool> same_party,
std::optional<std::string> source_scheme,
std::optional<int> source_port,
std::unique_ptr<Network::CookiePartitionKey> partition_key,
std::unique_ptr<SetCookieCallback> callback) {
if (!storage_partition_) {
callback->sendFailure(Response::InternalError());
return;
}
auto cookie_or_error = MakeCookieFromProtocolValues(
name, value, url.value_or(""), domain.value_or(""), path.value_or(""),
secure.value_or(false), http_only.value_or(false), same_site.value_or(""),
expires.value_or(-1), priority.value_or(""), source_scheme, source_port,
partition_key);
...
storage_partition_->GetCookieManagerForBrowserProcess()->SetCanonicalCookie(
*cookie, net::cookie_util::SimulatedCookieSource(*cookie, "https"),
options,
base::BindOnce(net::cookie_util::IsCookieAccessResultInclude)
.Then(base::BindOnce(&SetCookieCallback::sendSuccess,
std::move(callback))));
}
void NetworkHandler::DeleteCookies(
const std::string& name,
std::optional<std::string> url_spec,
std::optional<std::string> domain,
std::optional<std::string> path,
std::unique_ptr<Network::CookiePartitionKey> partition_key,
std::unique_ptr<DeleteCookiesCallback> callback) {
if (!storage_partition_) {
callback->sendFailure(Response::InternalError());
return;
}
if (!url_spec.has_value() && !domain.has_value()) {
callback->sendFailure(Response::InvalidParams(
"At least one of the url and domain needs to be specified"));
}
...
cookie_manager->GetAllCookies(
base::BindOnce(&DeleteFilteredCookies, base::Unretained(cookie_manager),
name, normalized_domain, path.value_or(""),
std::move(partition_key), std::move(callback)));
}
The storage domain DevTools handler exposes a similar cookie-clearing operation without policy checks:
// content/browser/devtools/protocol/storage_handler.cc
void StorageHandler::ClearCookies(
std::optional<std::string> browser_context_id,
std::unique_ptr<ClearCookiesCallback> callback) {
StoragePartition* storage_partition = nullptr;
Response response = StorageHandler::FindStoragePartition(browser_context_id,
&storage_partition);
if (!response.IsSuccess()) {
callback->sendFailure(std::move(response));
return;
}
storage_partition->GetCookieManagerForBrowserProcess()->DeleteCookies(
network::mojom::CookieDeletionFilter::New(),
base::BindOnce([](std::unique_ptr<ClearCookiesCallback> callback,
uint32_t) { callback->sendSuccess(); },
std::move(callback)));
}
As a result, once an extension is attached to a tab that is not policy-blocked, it can use chrome.debugger.sendCommand to invoke:
Network.setCookiewithurl: "https://www.google.com/", to create or overwrite a cookie on a host that is policy-blocked for the extension.Network.deleteCookieswithdomain: "www.google.com", to remove specific cookies for that host.Storage.clearCookies, to clear all cookies in the current profile’s cookie store, including those for policy-blocked sites such ashttps://www.google.com/.
The PoC extension in web/devtools_cookies/extension demonstrates this pattern. Its popup script attaches to the active tab and then calls DevTools cookie commands targeting https://www.google.com/:
// web/devtools_cookies/extension/popup.js
const PROTOCOL_VERSION = '1.3';
const TARGET_URL = 'https://www.google.com/';
const TARGET_DOMAIN = 'www.google.com';
...
function sendCommand(method, params) {
...
chrome.debugger.sendCommand(debuggee, method, params, (result) => {
...
});
}
document.getElementById('setCookie').addEventListener('click', () => {
ensureAttached((ok) => {
if (!ok) return;
const params = {
name: 'devtools_poc',
value: 'ts_' + Date.now(),
url: TARGET_URL,
path: '/',
secure: true,
httpOnly: false
};
sendCommand('Network.setCookie', params);
});
});
The extension only requires the debugger permission and user interaction to attach; it does not require host permissions for https://www.google.com/, and the enterprise runtime_blocked_hosts policy entry does not prevent these DevTools cookie operations.
Overall, this creates a discrepancy: cookie reads respect runtime_blocked_hosts via MayAttachToURL, while cookie writes/deletes/clears do not, allowing extensions to mutate cookie state on policy-blocked hosts.
2. vulnerability reproduction
- On Windows, import the provided policy file:
- Double-click
web/devtools_cookies/policy_runtime_blocked_hosts_google.regto add theExtensionSettingspolicy underHKCU\Software\Policies\Google\Chrome. - Restart Chrome and verify on
chrome://policythatExtensionSettingsis present andruntime_blocked_hostscontainshttps://www.google.com/*.
- Double-click
- In Chrome, open
chrome://extensions, enable Developer mode, and load the unpacked extension fromweb/devtools_cookies/extension. - Open any tab that is not
https://www.google.com(for example,https://example.com). - Click the extension’s toolbar icon:
- Click “Attach to active tab” (step 1) to call
chrome.debugger.attachfor the current tab. - Click “Set poc cookie on https://www.google.com/” (step 2). The extension invokes
Network.setCookiewithurl: "https://www.google.com/". - Optionally click “Delete poc cookie on https://www.google.com/” (step 3) and “Clear all cookies in current profile” (step 4), which invoke
Network.deleteCookiesandStorage.clearCookies.
- Click “Attach to active tab” (step 1) to call
- In a separate tab, visit
https://www.google.com(or clear and then visit it) and observe that:- The
devtools_poccookie is present or has been removed according to the actions performed by the extension. - If you were logged in, removing or clearing cookies can force sign-out and disrupt the session.
- The
These steps show that an extension subject to runtime_blocked_hosts: ["https://www.google.com/*"] can still modify (set, delete, or clear) cookies for https://www.google.com/ via DevTools cookie APIs, even though direct attachment to that URL is correctly blocked.
Impact analysis
-
Who can exploit it: Any installed extension that declares the
debuggerpermission and can run in an environment where administrators configureruntime_blocked_hoststo restrict host access (for example, addinghttps://www.google.com/*). A user must interact with the extension (e.g., click its action) to allow it to attach to some non-policy-blocked tab viachrome.debugger.attach. -
What they gain: Once attached to an allowed tab, the extension can use DevTools cookie APIs (
Network.setCookie,Network.deleteCookies,Storage.clearCookies) to modify cookies for hosts that are policy-blocked for that extension, includinghttps://www.google.com/. Practically, this enables:- Injecting or overwriting cookies on policy-blocked sites (which may influence site behavior or application state, and could become more serious in combination with site-side weaknesses such as session fixation vulnerabilities).
- Deleting specific cookies or clearing all cookies, forcing sign-outs and disrupting user sessions on policy-blocked sites.
-
Security significance (classification): This is best categorized as a Permissions Bypass / enterprise policy enforcement bypass for extension host restrictions. It does not grant the ability to read cookie values that are otherwise protected, nor does it provide remote code execution, a sandbox escape, or a privilege escalation beyond the extension’s existing capabilities. However, it weakens administrator expectations that
runtime_blocked_hostscompletely prevents extensions from influencing sensitive sites, by allowing extensions to tamper with cookie-based session and configuration state on those policy-blocked hosts.
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