CVE-2026-113275
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forextensions/browser/api/web_request/web_request_api_helpers.cc |
modified | |
ifextensions/browser/api/web_request/web_request_api_helpers.cc |
modified | |
ExtensionWebRequestTestchrome/browser/extensions/api/web_request/web_request_api_unittest.cc |
modified | |
TESTchrome/browser/extensions/api/web_request/web_request_api_unittest.cc |
modified |
Files Changed
extensions/browser/api/web_request/web_request_api_helpers.ccchrome/browser/extensions/api/web_request/web_request_api_unittest.cc
Patch
From ca8d9897639700c01a3983f85b926a6fe25dff7c Mon Sep 17 00:00:00 2001
From: Kelvin Jiang <kelvinjiang@chromium.org>
Date: Tue, 15 Sep 2026 16:52:20 -0700
Subject: [PATCH] [DNR] Enforce hidden response header restrictions in DNR modifyHeaders
In declarativeNetRequest, ModifyResponseHeadersForAction() applied
response header modifications without checking whether target headers
should be restricted via
ExtensionsAPIClient::ShouldHideResponseHeader().
This brings DNR into parity with the webRequest API's
CalculateOnHeadersReceivedDelta(), ensuring extensions cannot inject,
overwrite, or remove sensitive response headers.
Modifications targeting restricted headers on the request URL are
dropped, while any remaining unrestricted modifications in the rule are
still applied.
Fixed: 502040109
Change-Id: Ie19b1cf3b0aef104e58ae083115707233d5c78a1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8405278
Commit-Queue: Devlin Cronin <rdevlin.cronin@chromium.org>
Commit-Queue: Kelvin Jiang <kelvinjiang@chromium.org>
Reviewed-by: Devlin Cronin <rdevlin.cronin@chromium.org>
Auto-Submit: Kelvin Jiang <kelvinjiang@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1698510}
---
diff --git a/extensions/browser/api/web_request/web_request_api_helpers.cc b/extensions/browser/api/web_request/web_request_api_helpers.cc
index 9f2aa31..7b62b564 100644
--- a/extensions/browser/api/web_request/web_request_api_helpers.cc
+++ b/extensions/browser/api/web_request/web_request_api_helpers.cc
@@ -477,6 +477,7 @@
// |header_actions| maps a header name to a list of operations to be performed
// on the header.
bool ModifyResponseHeadersForAction(
+ const GURL& url,
const net::HttpResponseHeaders* original_response_headers,
scoped_refptr<net::HttpResponseHeaders>* override_response_headers,
const DNRRequestAction& request_action,
@@ -504,10 +505,15 @@
}
};
+ extensions::ExtensionsAPIClient* api_client =
+ extensions::ExtensionsAPIClient::Get();
for (const DNRRequestAction::HeaderInfo& header_info :
request_action.response_headers_to_modify) {
bool header_modified = false;
const std::string& header = header_info.header;
+ if (api_client && api_client->ShouldHideResponseHeader(url, header)) {
+ continue;
+ }
DNRHeaderAction header_action(&header_info, &request_action.extension_id);
auto iter = header_actions->find(header);
@@ -1601,8 +1607,8 @@
std::map<std::string_view, std::vector<DNRHeaderAction>> dnr_header_actions;
for (const auto& action : *request.dnr_actions) {
bool headers_modified_for_action = ModifyResponseHeadersForAction(
- original_response_headers, override_response_headers, action,
- &dnr_header_actions);
+ request.url, original_response_headers, override_response_headers,
+ action, &dnr_header_actions);
*response_headers_modified |= headers_modified_for_action;
if (headers_modified_for_action) {
diff --git a/chrome/browser/extensions/api/web_request/web_request_api_unittest.cc b/chrome/browser/extensions/api/web_request/web_request_api_unittest.cc
index 9e52e0c..e77f9130 100644
--- a/chrome/browser/extensions/api/web_request/web_request_api_unittest.cc
+++ b/chrome/browser/extensions/api/web_request/web_request_api_unittest.cc
@@ -80,6 +80,7 @@
using helpers::InDecreasingExtensionInstallationTimeOrder;
using helpers::MergeCancelOfResponses;
using helpers::MergeOnBeforeRequestResponses;
+using helpers::MergeOnHeadersReceivedResponses;
using helpers::RequestCookieModification;
using helpers::ResponseCookieModification;
using helpers::ResponseHeader;
@@ -93,6 +94,7 @@
namespace {
constexpr const char kExampleUrl[] = "http://example.com";
+constexpr const char kGaiaHeader[] = "x-chrome-id-consistency-response";
// Returns whether |warnings| contains an extension for |extension_id|.
bool HasIgnoredAction(const helpers::IgnoredActions& ignored_actions,
@@ -107,6 +109,43 @@
return false;
}
+scoped_refptr<net::HttpResponseHeaders> MergeOnHeadersReceivedResponses(
+ const GURL& url,
+ scoped_refptr<net::HttpResponseHeaders> base_headers,
+ std::vector<DNRRequestAction> dnr_actions,
+ bool* response_headers_modified,
+ std::vector<const DNRRequestAction*>* matched_actions) {
+ WebRequestInfoInitParams info_params;
+ info_params.url = url;
+ WebRequestInfo info(std::move(info_params));
+ info.dnr_actions = std::move(dnr_actions);
+
+ helpers::IgnoredActions ignored_actions;
+ EventResponseDeltas deltas;
+ GURL preserve_fragment_on_redirect_url;
+ std::optional<ExtensionId> extension_id;
+ scoped_refptr<net::HttpResponseHeaders> new_headers;
+
+ helpers::MergeOnHeadersReceivedResponses(
+ info, deltas, base_headers.get(), &new_headers,
+ &preserve_fragment_on_redirect_url, &extension_id, &ignored_actions,
+ response_headers_modified, matched_actions);
+ return new_headers;
+}
+
+scoped_refptr<net::HttpResponseHeaders> MergeOnHeadersReceivedResponses(
+ const GURL& url,
+ scoped_refptr<net::HttpResponseHeaders> base_headers,
+ DNRRequestAction dnr_action,
+ bool* response_headers_modified,
+ std::vector<const DNRRequestAction*>* matched_actions) {
+ std::vector<DNRRequestAction> dnr_actions;
+ dnr_actions.push_back(std::move(dnr_action));
+ return MergeOnHeadersReceivedResponses(
+ url, base_headers, std::move(dnr_actions), response_headers_modified,
+ matched_actions);
+}
+
} // namespace
class ExtensionWebRequestTest : public testing::Test {
@@ -2382,6 +2421,186 @@
// removed.
}
+// Tests that declarative net request actions cannot modify response headers
+// hidden by ExtensionsAPIClient::ShouldHideResponseHeader() (such as
+// x-chrome-id-consistency-response on Gaia URLs), whether injecting, mutating,
+// or removing the header.
+TEST(ExtensionWebRequestHelpersTest,
+ TestMergeOnHeadersReceivedResponses_DNRCannotModifyHiddenHeaders) {
+ using HeaderInfo = DNRRequestAction::HeaderInfo;
+ const GURL gaia_url = GaiaUrls::GetInstance()->gaia_url();
+ const GURL non_gaia_url = GURL(kExampleUrl);
+
+ // When the header is initially absent on the server response:
+ {
+ char base_headers_string[] =
+ "HTTP/1.0 200 OK\r\n"
+ "X-Control: server_value\r\n"
+ "\r\n";
+ auto base_headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ net::HttpUtil::AssembleRawHeaders(base_headers_string));
+
+ auto make_action = [] {
+ DNRRequestAction action =
+ CreateRequestActionForTesting(DNRRequestAction::Type::MODIFY_HEADERS);
+ action.extension_id = "ext_1";
+ action.response_headers_to_modify = {HeaderInfo(
+ kGaiaHeader, dnr_api::HeaderOperation::kSet, "injected_value")};
+ return action;
+ };
+
+ // Gaia URL: extension cannot inject a new Gaia header.
+ {
+ bool headers_modified = false;
+ std::vector<const DNRRequestAction*> matched_actions;
+ auto new_headers =
+ MergeOnHeadersReceivedResponses(gaia_url, base_headers, make_action(),
+ &headers_modified, &matched_actions);
+ EXPECT_FALSE(headers_modified);
+ EXPECT_FALSE(new_headers);
+ EXPECT_TRUE(matched_actions.empty());
+ EXPECT_FALSE(base_headers->HasHeader(kGaiaHeader));
+ }
+
+ // Control: non-Gaia URL allows injecting the header.
+ {
+ bool headers_modified = false;
+ std::vector<const DNRRequestAction*> matched_actions;
+ auto new_headers = MergeOnHeadersReceivedResponses(
+ non_gaia_url, base_headers, make_action(), &headers_modified,
+ &matched_actions);
+ ASSERT_TRUE(new_headers);
+ EXPECT_TRUE(headers_modified);
+ EXPECT_EQ(1u, matched_actions.size());
+ EXPECT_EQ("injected_value",
+ new_headers->GetNormalizedHeader(kGaiaHeader));
+ }
+ }
+
+ // When the header is initially present on the server response:
+ {
+ char base_headers_string[] =
+ "HTTP/1.0 200 OK\r\n"
+ "X-Chrome-ID-Consistency-Response: server_value\r\n"
+ "X-Control: server_control\r\n"
+ "\r\n";
+ auto base_headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ net::HttpUtil::AssembleRawHeaders(base_headers_string));
+
+ // Gaia URL: cannot overwrite (kSet).
+ {
+ DNRRequestAction action =
+ CreateRequestActionForTesting(DNRRequestAction::Type::MODIFY_HEADERS);
+ action.extension_id = "ext_1";
+ action.response_headers_to_modify = {HeaderInfo(
+ kGaiaHeader, dnr_api::HeaderOperation::kSet, "overwrite_value")};
+
+ bool headers_modified = false;
+ std::vector<const DNRRequestAction*> matched_actions;
+ auto new_headers = MergeOnHeadersReceivedResponses(
+ gaia_url, base_headers, std::move(action), &headers_modified,
+ &matched_actions);
+ EXPECT_FALSE(headers_modified);
+ EXPECT_FALSE(new_headers);
+ EXPECT_TRUE(matched_actions.empty());
+ EXPECT_EQ("server_value", base_headers->GetNormalizedHeader(kGaiaHeader));
+ }
+
+ // Gaia URL: cannot append (kAppend).
+ {
+ DNRRequestAction action =
+ CreateRequestActionForTesting(DNRRequestAction::Type::MODIFY_HEADERS);
+ action.extension_id = "ext_1";
+ action.response_headers_to_modify = {HeaderInfo(
+ kGaiaHeader, dnr_api::HeaderOperation::kAppend, "append_value")};
+
+ bool headers_modified = false;
+ std::vector<const DNRRequestAction*> matched_actions;
+ auto new_headers = MergeOnHeadersReceivedResponses(
+ gaia_url, base_headers, std::move(action), &headers_modified,
+ &matched_actions);
+ EXPECT_FALSE(headers_modified);
+ EXPECT_FALSE(new_headers);
+ EXPECT_TRUE(matched_actions.empty());
+ EXPECT_EQ("server_value", base_headers->GetNormalizedHeader(kGaiaHeader));
+ }
+
+ // Gaia URL: cannot remove (kRemove).
+ {
+ DNRRequestAction action =
+ CreateRequestActionForTesting(DNRRequestAction::Type::MODIFY_HEADERS);
+ action.extension_id = "ext_1";
+ action.response_headers_to_modify = {HeaderInfo(
+ kGaiaHeader, dnr_api::HeaderOperation::kRemove, std::nullopt)};
+
+ bool headers_modified = false;
+ std::vector<const DNRRequestAction*> matched_actions;
+ auto new_headers = MergeOnHeadersReceivedResponses(
+ gaia_url, base_headers, std::move(action), &headers_modified,
+ &matched_actions);
+ EXPECT_FALSE(headers_modified);
+ EXPECT_FALSE(new_headers);
+ EXPECT_TRUE(matched_actions.empty());
+ EXPECT_TRUE(base_headers->HasHeader(kGaiaHeader));
+ EXPECT_EQ("server_value", base_headers->GetNormalizedHeader(kGaiaHeader));
+ }
+
+ // Control: non-Gaia URL allows removal.
+ {
+ DNRRequestAction action =
+ CreateRequestActionForTesting(DNRRequestAction::Type::MODIFY_HEADERS);
+ action.extension_id = "ext_1";
+ action.response_headers_to_modify = {HeaderInfo(
+ kGaiaHeader, dnr_api::HeaderOperation::kRemove, std::nullopt)};
+
+ bool headers_modified = false;
+ std::vector<const DNRRequestAction*> matched_actions;
+ auto new_headers = MergeOnHeadersReceivedResponses(
+ non_gaia_url, base_headers, std::move(action), &headers_modified,
+ &matched_actions);
+ ASSERT_TRUE(new_headers);
+ EXPECT_TRUE(headers_modified);
+ EXPECT_EQ(1u, matched_actions.size());
+ EXPECT_FALSE(new_headers->HasHeader(kGaiaHeader));
+ }
+ }
+}
+
+// Tests that when a declarative net request rule modifies multiple headers,
+// restricted headers are dropped while unrestricted headers are still applied.
+TEST(ExtensionWebRequestHelpersTest,
+ TestMergeOnHeadersReceivedResponses_DNRPartialHeaderModification) {
+ using HeaderInfo = DNRRequestAction::HeaderInfo;
+ const GURL gaia_url = GaiaUrls::GetInstance()->gaia_url();
+
+ char base_headers_string[] =
+ "HTTP/1.0 200 OK\r\n"
+ "X-Control: server_value\r\n"
+ "\r\n";
+ auto base_headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ net::HttpUtil::AssembleRawHeaders(base_headers_string));
+
+ DNRRequestAction action =
+ CreateRequestActionForTesting(DNRRequestAction::Type::MODIFY_HEADERS);
+ action.extension_id = "ext_1";
+ action.response_headers_to_modify = {
+ HeaderInfo(kGaiaHeader, dnr_api::HeaderOperation::kSet, "bad_val_1"),
+ HeaderInfo(kGaiaHeader, dnr_api::HeaderOperation::kAppend, "bad_val_2"),
+ HeaderInfo("x-control", dnr_api::HeaderOperation::kSet, "good_val")};
+
+ // Gaia URL: hidden headers are dropped, but allowed header is applied.
+ bool headers_modified = false;
+ std::vector<const DNRRequestAction*> matched_actions;
+ auto new_headers =
+ MergeOnHeadersReceivedResponses(gaia_url, base_headers, std::move(action),
+ &headers_modified, &matched_actions);
+ ASSERT_TRUE(new_headers);
+ EXPECT_TRUE(headers_modified);
+ EXPECT_EQ(1u, matched_actions.size());
+ EXPECT_FALSE(new_headers->HasHeader(kGaiaHeader));
+ EXPECT_EQ("good_val", new_headers->GetNormalizedHeader("X-Control"));
+}
+
TEST(ExtensionWebRequestHelpersTest, TestMergeOnAuthRequiredResponses) {
helpers::IgnoredActions ignored_actions;
EventResponseDeltas deltas;
Regression Test / PoC
diff --git a/chrome/browser/extensions/api/web_request/web_request_api_unittest.cc b/chrome/browser/extensions/api/web_request/web_request_api_unittest.cc
index 9e52e0c..e77f9130 100644
--- a/chrome/browser/extensions/api/web_request/web_request_api_unittest.cc
+++ b/chrome/browser/extensions/api/web_request/web_request_api_unittest.cc
@@ -80,6 +80,7 @@
using helpers::InDecreasingExtensionInstallationTimeOrder;
using helpers::MergeCancelOfResponses;
using helpers::MergeOnBeforeRequestResponses;
+using helpers::MergeOnHeadersReceivedResponses;
using helpers::RequestCookieModification;
using helpers::ResponseCookieModification;
using helpers::ResponseHeader;
@@ -93,6 +94,7 @@
namespace {
constexpr const char kExampleUrl[] = "http://example.com";
+constexpr const char kGaiaHeader[] = "x-chrome-id-consistency-response";
// Returns whether |warnings| contains an extension for |extension_id|.
bool HasIgnoredAction(const helpers::IgnoredActions& ignored_actions,
@@ -107,6 +109,43 @@
return false;
}
+scoped_refptr<net::HttpResponseHeaders> MergeOnHeadersReceivedResponses(
+ const GURL& url,
+ scoped_refptr<net::HttpResponseHeaders> base_headers,
+ std::vector<DNRRequestAction> dnr_actions,
+ bool* response_headers_modified,
+ std::vector<const DNRRequestAction*>* matched_actions) {
+ WebRequestInfoInitParams info_params;
+ info_params.url = url;
+ WebRequestInfo info(std::move(info_params));
+ info.dnr_actions = std::move(dnr_actions);
+
+ helpers::IgnoredActions ignored_actions;
+ EventResponseDeltas deltas;
+ GURL preserve_fragment_on_redirect_url;
+ std::optional<ExtensionId> extension_id;
+ scoped_refptr<net::HttpResponseHeaders> new_headers;
+
+ helpers::MergeOnHeadersReceivedResponses(
+ info, deltas, base_headers.get(), &new_headers,
+ &preserve_fragment_on_redirect_url, &extension_id, &ignored_actions,
+ response_headers_modified, matched_actions);
+ return new_headers;
+}
+
+scoped_refptr<net::HttpResponseHeaders> MergeOnHeadersReceivedResponses(
+ const GURL& url,
+ scoped_refptr<net::HttpResponseHeaders> base_headers,
+ DNRRequestAction dnr_action,
+ bool* response_headers_modified,
+ std::vector<const DNRRequestAction*>* matched_actions) {
+ std::vector<DNRRequestAction> dnr_actions;
+ dnr_actions.push_back(std::move(dnr_action));
+ return MergeOnHeadersReceivedResponses(
+ url, base_headers, std::move(dnr_actions), response_headers_modified,
+ matched_actions);
+}
+
} // namespace
class ExtensionWebRequestTest : public testing::Test {
@@ -2382,6 +2421,186 @@
// removed.
}
+// Tests that declarative net request actions cannot modify response headers
+// hidden by ExtensionsAPIClient::ShouldHideResponseHeader() (such as
+// x-chrome-id-consistency-response on Gaia URLs), whether injecting, mutating,
+// or removing the header.
+TEST(ExtensionWebRequestHelpersTest,
+ TestMergeOnHeadersReceivedResponses_DNRCannotModifyHiddenHeaders) {
+ using HeaderInfo = DNRRequestAction::HeaderInfo;
+ const GURL gaia_url = GaiaUrls::GetInstance()->gaia_url();
+ const GURL non_gaia_url = GURL(kExampleUrl);
+
+ // When the header is initially absent on the server response:
+ {
+ char base_headers_string[] =
+ "HTTP/1.0 200 OK\r\n"
+ "X-Control: server_value\r\n"
+ "\r\n";
+ auto base_headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ net::HttpUtil::AssembleRawHeaders(base_headers_string));
+
+ auto make_action = [] {
+ DNRRequestAction action =
+ CreateRequestActionForTesting(DNRRequestAction::Type::MODIFY_HEADERS);
+ action.extension_id = "ext_1";
+ action.response_headers_to_modify = {HeaderInfo(
+ kGaiaHeader, dnr_api::HeaderOperation::kSet, "injected_value")};
+ return action;
+ };
+
+ // Gaia URL: extension cannot inject a new Gaia header.
+ {
+ bool headers_modified = false;
+ std::vector<const DNRRequestAction*> matched_actions;
+ auto new_headers =
+ MergeOnHeadersReceivedResponses(gaia_url, base_headers, make_action(),
+ &headers_modified, &matched_actions);
+ EXPECT_FALSE(headers_modified);
+ EXPECT_FALSE(new_headers);
+ EXPECT_TRUE(matched_actions.empty());
+ EXPECT_FALSE(base_headers->HasHeader(kGaiaHeader));
+ }
+
+ // Control: non-Gaia URL allows injecting the header.
+ {
+ bool headers_modified = false;
+ std::vector<const DNRRequestAction*> matched_actions;
+ auto new_headers = MergeOnHeadersReceivedResponses(
+ non_gaia_url, base_headers, make_action(), &headers_modified,
+ &matched_actions);
+ ASSERT_TRUE(new_headers);
+ EXPECT_TRUE(headers_modified);
+ EXPECT_EQ(1u, matched_actions.size());
+ EXPECT_EQ("injected_value",
+ new_headers->GetNormalizedHeader(kGaiaHeader));
+ }
+ }
+
+ // When the header is initially present on the server response:
+ {
+ char base_headers_string[] =
+ "HTTP/1.0 200 OK\r\n"
+ "X-Chrome-ID-Consistency-Response: server_value\r\n"
+ "X-Control: server_control\r\n"
+ "\r\n";
+ auto base_headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ net::HttpUtil::AssembleRawHeaders(base_headers_string));
+
+ // Gaia URL: cannot overwrite (kSet).
+ {
+ DNRRequestAction action =
+ CreateRequestActionForTesting(DNRRequestAction::Type::MODIFY_HEADERS);
+ action.extension_id = "ext_1";
+ action.response_headers_to_modify = {HeaderInfo(
+ kGaiaHeader, dnr_api::HeaderOperation::kSet, "overwrite_value")};
+
+ bool headers_modified = false;
+ std::vector<const DNRRequestAction*> matched_actions;
+ auto new_headers = MergeOnHeadersReceivedResponses(
+ gaia_url, base_headers, std::move(action), &headers_modified,
+ &matched_actions);
+ EXPECT_FALSE(headers_modified);
+ EXPECT_FALSE(new_headers);
+ EXPECT_TRUE(matched_actions.empty());
+ EXPECT_EQ("server_value", base_headers->GetNormalizedHeader(kGaiaHeader));
+ }
+
+ // Gaia URL: cannot append (kAppend).
+ {
+ DNRRequestAction action =
+ CreateRequestActionForTesting(DNRRequestAction::Type::MODIFY_HEADERS);
+ action.extension_id = "ext_1";
+ action.response_headers_to_modify = {HeaderInfo(
+ kGaiaHeader, dnr_api::HeaderOperation::kAppend, "append_value")};
+
+ bool headers_modified = false;
+ std::vector<const DNRRequestAction*> matched_actions;
+ auto new_headers = MergeOnHeadersReceivedResponses(
+ gaia_url, base_headers, std::move(action), &headers_modified,
+ &matched_actions);
+ EXPECT_FALSE(headers_modified);
+ EXPECT_FALSE(new_headers);
+ EXPECT_TRUE(matched_actions.empty());
+ EXPECT_EQ("server_value", base_headers->GetNormalizedHeader(kGaiaHeader));
+ }
+
+ // Gaia URL: cannot remove (kRemove).
+ {
+ DNRRequestAction action =
+ CreateRequestActionForTesting(DNRRequestAction::Type::MODIFY_HEADERS);
+ action.extension_id = "ext_1";
+ action.response_headers_to_modify = {HeaderInfo(
+ kGaiaHeader, dnr_api::HeaderOperation::kRemove, std::nullopt)};
+
+ bool headers_modified = false;
+ std::vector<const DNRRequestAction*> matched_actions;
+ auto new_headers = MergeOnHeadersReceivedResponses(
+ gaia_url, base_headers, std::move(action), &headers_modified,
+ &matched_actions);
+ EXPECT_FALSE(headers_modified);
+ EXPECT_FALSE(new_headers);
+ EXPECT_TRUE(matched_actions.empty());
+ EXPECT_TRUE(base_headers->HasHeader(kGaiaHeader));
+ EXPECT_EQ("server_value", base_headers->GetNormalizedHeader(kGaiaHeader));
+ }
+
+ // Control: non-Gaia URL allows removal.
+ {
+ DNRRequestAction action =
+ CreateRequestActionForTesting(DNRRequestAction::Type::MODIFY_HEADERS);
+ action.extension_id = "ext_1";
+ action.response_headers_to_modify = {HeaderInfo(
+ kGaiaHeader, dnr_api::HeaderOperation::kRemove, std::nullopt)};
+
+ bool headers_modified = false;
+ std::vector<const DNRRequestAction*> matched_actions;
+ auto new_headers = MergeOnHeadersReceivedResponses(
+ non_gaia_url, base_headers, std::move(action), &headers_modified,
+ &matched_actions);
+ ASSERT_TRUE(new_headers);
+ EXPECT_TRUE(headers_modified);
+ EXPECT_EQ(1u, matched_actions.size());
+ EXPECT_FALSE(new_headers->HasHeader(kGaiaHeader));
+ }
+ }
+}
+
+// Tests that when a declarative net request rule modifies multiple headers,
+// restricted headers are dropped while unrestricted headers are still applied.
+TEST(ExtensionWebRequestHelpersTest,
+ TestMergeOnHeadersReceivedResponses_DNRPartialHeaderModification) {
+ using HeaderInfo = DNRRequestAction::HeaderInfo;
+ const GURL gaia_url = GaiaUrls::GetInstance()->gaia_url();
+
+ char base_headers_string[] =
+ "HTTP/1.0 200 OK\r\n"
+ "X-Control: server_value\r\n"
+ "\r\n";
+ auto base_headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ net::HttpUtil::AssembleRawHeaders(base_headers_string));
+
+ DNRRequestAction action =
+ CreateRequestActionForTesting(DNRRequestAction::Type::MODIFY_HEADERS);
+ action.extension_id = "ext_1";
+ action.response_headers_to_modify = {
+ HeaderInfo(kGaiaHeader, dnr_api::HeaderOperation::kSet, "bad_val_1"),
+ HeaderInfo(kGaiaHeader, dnr_api::HeaderOperation::kAppend, "bad_val_2"),
+ HeaderInfo("x-control", dnr_api::HeaderOperation::kSet, "good_val")};
+
+ // Gaia URL: hidden headers are dropped, but allowed header is applied.
+ bool headers_modified = false;
+ std::vector<const DNRRequestAction*> matched_actions;
+ auto new_headers =
+ MergeOnHeadersReceivedResponses(gaia_url, base_headers, std::move(action),
+ &headers_modified, &matched_actions);
+ ASSERT_TRUE(new_headers);
+ EXPECT_TRUE(headers_modified);
+ EXPECT_EQ(1u, matched_actions.size());
+ EXPECT_FALSE(new_headers->HasHeader(kGaiaHeader));
+ EXPECT_EQ("good_val", new_headers->GetNormalizedHeader("X-Control"));
+}
+
TEST(ExtensionWebRequestHelpersTest, TestMergeOnAuthRequiredResponses) {
helpers::IgnoredActions ignored_actions;
EventResponseDeltas deltas;
Original Bug Report
DICE account injection via declarativeNetRequest modifyHeaders bypass
Project Fortify, 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 without the Chrome Security team.
Overview: The declarativeNetRequest API fails to enforce response header visibility restrictions during modifyHeaders actions. A malicious extension could exploit this bypass to inject the sensitive X-Chrome-ID-Consistency-Response header into responses from accounts.google.com. This potentially allows an attacker to silently inject their Google account into the victim’s Chrome profile.
Affected files:
extensions/browser/api/web_request/web_request_api_helpers.ccextensions/browser/api/declarative_net_request/indexed_rule.ccchrome/browser/extensions/api/chrome_extensions_api_client.ccextensions/browser/api/web_request/web_request_permissions.cc
Estimated timestamp from git blame: 2026-01-06
Description
In Chrome, the X-Chrome-ID-Consistency-Response (DICE) header is a sensitive response header used to manage account sign-ins and identity consistency. To prevent malicious extensions from reading or spoofing this header to hijack sessions or inject accounts, ChromeExtensionsAPIClient::ShouldHideResponseHeader() explicitly blocks extensions from accessing this header when the response originates from Google’s Gaia servers (e.g., accounts.google.com).
However, there is a discrepancy in how header modifications are validated between the legacy Web Request API and the declarativeNetRequest (DNR) API. While the legacy path (CalculateOnHeadersReceivedDelta in extensions/browser/api/web_request/web_request_api_helpers.cc) correctly calls ShouldHideResponseHeader(), the DNR implementation (ModifyResponseHeadersForAction in the same file) does not.
When a DNR modifyHeaders rule is processed, ModifyResponseHeadersForAction blindly applies kSet and kAppend operations to the override_response_headers object without checking if the target header is restricted. Furthermore, DNR rule validation in indexed_rule.cc lacks a blocklist for response headers.
Potential Impact
Because the injected header originates from the WebRequestProxyingURLLoaderFactory intercepting a legitimate Gaia request, Chrome’s internal sign-in components (like signin::URLLoaderThrottle and DiceResponseHandler) trust the header as if it was sent by Google.
An attacker can obtain a valid authorization_code for their own Google account and use a DNR rule to inject it. Chrome will then silently exchange this code for a refresh token and add the attacker’s account to the victim’s browser IdentityManager. This constitutes a persistent account injection/session fixation attack.
Potential Reproduction Steps
Note: These are suggested steps based on code analysis; our tooling agent does not currently have the capability to execute a working proof of concept.
- An attacker publishes a Manifest V3 extension requesting the
declarativeNetRequestpermission and host permissions for*://accounts.google.com/*. - The extension registers a static DNR rule that targets main-frame navigations (
"resourceTypes": ["main_frame"]) toaccounts.google.com. - The rule specifies a
modifyHeadersaction to set theX-Chrome-ID-Consistency-Responseheader to a crafted payload:action=SIGNIN,id=<attacker_id>,email=<attacker_email>,authuser=0,authorization_code=<attacker_code> - The attacker acquires a fresh, unused
authorization_codefor their account and populates it in the rule. - The victim installs the extension and later navigates to any
accounts.google.compage. - The DNR engine successfully injects the restricted DICE header.
signin::URLLoaderThrottleintercepts the response, extracts the header, and passes it toDiceResponseHandler.- Chrome exchanges the code for a refresh token and adds the attacker’s account to the victim’s Chrome profile.
Proposed Fix
- Enforce Restrictions in DNR Processing: Update
ModifyResponseHeadersForActioninextensions/browser/api/web_request/web_request_api_helpers.ccto consultExtensionsAPIClient::ShouldHideResponseHeader()(or a similar mechanism) before applyingkSetorkAppendoperations to response headers. If the header should be hidden, the modification action should be dropped. - Rule Validation (Optional but Recommended): Consider adding a response header blocklist to
ValidateHeadersForModificationinextensions/browser/api/declarative_net_request/indexed_rule.ccto prevent rules targeting known-sensitive headers from being successfully registered in the first place.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.