CVE-2026-87626
Overview
Background
- `ShouldForceIgnoreSiteForCookies`
- A
network::url_loader_utilhelper that decides whether a request may ignore SameSite restrictions so that SameSite=Strict/Lax cookies are attached (typically for Chrome-extension-initiated requests). - `force_ignore_site_for_cookies`
- The request flag set from that helper’s return value; when true the request is treated as same-site so Strict/Lax cookies are sent.
- `cors::OriginAccessList`
- An allowlist that grants a given origin (e.g., an extension identified by
request_initiatororsite_for_cookies) same-origin-style access to specific hosts, queried viaCheckAccessState. - `url_chain()`
- A
net::URLRequest’s ordered list of every URL it has visited across redirect hops, whereasurl()returns only the current (last) entry.
Root Cause Analysis
The vulnerable path is ShouldForceIgnoreSiteForCookies, which NetworkServiceNetworkDelegate::OnShouldForceIgnoreSiteForCookies re-queries at every redirect hop of a request.
Pre-patch, the helper took a single const GURL& url (request.url(), i.e. the current URL) and returned true whenever request_initiator had OriginAccessList access to that one URL — or, on the site_for_cookies path, when the extension origin could access the initiator and that single URL and the two were same-site. The violated invariant is that the SameSite bypass should only apply when the authorizing origin has access to the entire request chain: because only the latest URL was checked, a chain that traversed a host the extension had no access to could still acquire the bypass at a later, permitted hop, and the two authorization paths could each satisfy different hops of the same request.
The fix changes the parameter to const std::vector<GURL>& url_chain, adds an empty-chain guard returning false, and requires that one consistent authorization path (initiator access, or site_for_cookies access plus same-site) hold for every URL in the chain, breaking out with false on the first failing hop. This works because the bypass is now granted only when the whole redirect chain is uniformly authorized, so an unauthorized intermediary can no longer launder a request into same-site treatment.
request.url() even though the decision recurs on every redirect hop; the fix iterates over url_chain and requires one consistent origin-access/same-site authorization path to cover every hop, returning false as soon as any hop fails.Attack Path
- Extension issues a no-cors request to an unauthorized host
A request is initiated whose first URL is a host absent from the extension’s
cors::OriginAccessList(the test’skNotPermittedHost/disallowed.example). - Unauthorized host 307-redirects to a permitted host
The intermediary responds with a redirect to a host the extension does have access to (
kPermittedHost/allowed.example), building aurl_chainof[disallowed, permitted]. - Bypass acquired mid-chain
OnShouldForceIgnoreSiteForCookiesis re-queried after the redirect withrequest.url()equal to the permitted host; pre-patchShouldForceIgnoreSiteForCookiesinspects only that URL, the initiator-access check passes, and it returnstrue. - Strict/Lax cookies sent across an origin-access boundary
force_ignore_site_for_cookiesis set true, so SameSite=Strict/Lax cookies for the permitted host are attached even though the chain passed through a host the extension is not authorized for, defeating SameSite protection. - Incorrect authorization outcome
The improperly same-site-treated, cookie-bearing request yields the incorrect authorization the advisory attributes to
DeviceBoundSessionCredentials(inferred; the patch demonstrates only the SameSite cookie attachment, not the downstream credential/session effect).
Impact Assessment
OriginAccessList grants access to some hosts initiating a (no-cors) request that either enters or leaves those authorized origins via a redirect. The advisory classifies this as “Incorrect authorization in DeviceBoundSessionCredentials”; any authentication or session-credential compromise beyond the demonstrated SameSite cookie leak is (inferred), and the provided material makes no exploited-in-the-wild claim.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/network/network_service_network_delegate.cc |
modified | |
forservices/network/url_loader_util.cc |
modified | |
ifservices/network/url_loader_util.cc |
modified | |
BindLambdaForTestingservices/network/network_context_unittest.cc |
modified | |
TEST_Fservices/network/network_context_unittest.cc |
modified |
Files Changed
services/network/url_loader_util_unittest.ccservices/network/network_context_unittest.ccservices/network/url_loader_util.ccservices/network/network_service_network_delegate.hservices/network/url_loader_util.hservices/network/network_service_network_delegate.ccchrome/browser/extensions/extension_cookies_browsertest.cc
Audit Directions
- Per-hop authorization on `url()`Flag security decisions that authorize using
request.url()(the current URL) when the decision is re-evaluated across redirects; they should consulturl_chain()and require every hop to pass. - Bypass flags gated on origin accessReview other places that set request bypass flags such as
force_ignore_site_for_cookiesfromOriginAccessList::CheckAccessState, ensuring the grant covers the whole request rather than one URL. - Combinable authorization branchesLook for helpers with multiple independent authorization paths where, across a multi-step request, different branches could each satisfy different steps and thereby combine into an unintended grant.
Patch
From c25b678887fda4fd84d0041857115d6ca6c1c32f Mon Sep 17 00:00:00 2001
From: Ho Cheung <hocheung@chromium.org>
Date: Fri, 28 Aug 2026 10:38:16 -0700
Subject: [PATCH] Check URL chain for SameSite bypass
Require every URL in the request chain to pass one consistent origin
access check before ignoring SameSite restrictions.
And add regression tests.
Fixed: 553155590
Change-Id: I2723ac7ca88de6f199e25a035a610e5154da372a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8300375
Reviewed-by: Maks Orlovich <morlovich@chromium.org>
Reviewed-by: James Cook <jamescook@chromium.org>
Commit-Queue: Aaron Teo <hocheung@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1688135}
---
diff --git a/services/network/network_service_network_delegate.cc b/services/network/network_service_network_delegate.cc
index 1d0afb6..e41cbdd3 100644
--- a/services/network/network_service_network_delegate.cc
+++ b/services/network/network_service_network_delegate.cc
@@ -67,6 +67,13 @@
NetworkServiceNetworkDelegate::~NetworkServiceNetworkDelegate() = default;
+void NetworkServiceNetworkDelegate::
+ SetShouldForceIgnoreSiteForCookiesCallbackForTesting(
+ base::RepeatingCallback<void(const net::URLRequest&)> callback) {
+ should_force_ignore_site_for_cookies_callback_for_testing_ =
+ std::move(callback);
+}
+
void NetworkServiceNetworkDelegate::MaybeTruncateReferrer(
net::URLRequest* const request,
const GURL& effective_url) {
@@ -297,8 +304,11 @@
bool NetworkServiceNetworkDelegate::OnShouldForceIgnoreSiteForCookies(
const net::URLRequest& request) {
+ if (should_force_ignore_site_for_cookies_callback_for_testing_) {
+ should_force_ignore_site_for_cookies_callback_for_testing_.Run(request);
+ }
return url_loader_util::ShouldForceIgnoreSiteForCookies(
- request.url(), request.initiator(), request.site_for_cookies(),
+ request.url_chain(), request.initiator(), request.site_for_cookies(),
network_context_->cors_origin_access_list());
}
diff --git a/services/network/network_service_network_delegate.h b/services/network/network_service_network_delegate.h
index d628564..39d6739 100644
--- a/services/network/network_service_network_delegate.h
+++ b/services/network/network_service_network_delegate.h
@@ -8,6 +8,7 @@
#include <optional>
#include "base/component_export.h"
+#include "base/functional/callback.h"
#include "base/memory/raw_ptr.h"
#include "mojo/public/cpp/bindings/pending_remote.h"
#include "mojo/public/cpp/bindings/remote.h"
@@ -48,6 +49,10 @@
enable_referrers_ = enable_referrers;
}
+ // Sets a callback invoked by OnShouldForceIgnoreSiteForCookies().
+ void SetShouldForceIgnoreSiteForCookiesCallbackForTesting(
+ base::RepeatingCallback<void(const net::URLRequest&)> callback);
+
private:
// net::NetworkDelegateImpl implementation.
int OnBeforeURLRequest(net::URLRequest* request,
@@ -127,6 +132,9 @@
mojo::Remote<mojom::ProxyErrorClient> proxy_error_client_;
raw_ptr<NetworkContext> network_context_;
+ base::RepeatingCallback<void(const net::URLRequest&)>
+ should_force_ignore_site_for_cookies_callback_for_testing_;
+
mutable base::WeakPtrFactory<NetworkServiceNetworkDelegate> weak_ptr_factory_{
this};
};
diff --git a/services/network/url_loader_util.cc b/services/network/url_loader_util.cc
index 2a5cbd1..3d87779 100644
--- a/services/network/url_loader_util.cc
+++ b/services/network/url_loader_util.cc
@@ -220,17 +220,33 @@
} // namespace
bool ShouldForceIgnoreSiteForCookies(
- const GURL& url,
+ const std::vector<GURL>& url_chain,
const std::optional<url::Origin>& request_initiator,
const net::SiteForCookies& site_for_cookies,
const cors::OriginAccessList& origin_access_list) {
+ if (url_chain.empty()) {
+ return false;
+ }
+
+ // This decision may be queried again after a redirect. One authorization
+ // path must cover every hop so a request cannot gain or retain the SameSite
+ // bypass across an origin access boundary.
+
// Ignore site for cookies in requests from an initiator covered by the
- // same-origin-policy exclusions in `origin_access_list_` (typically requests
+ // same-origin-policy exclusions in `origin_access_list` (typically requests
// initiated by Chrome Extensions).
- if (request_initiator.has_value() &&
- cors::OriginAccessList::AccessState::kAllowed ==
+ if (request_initiator.has_value()) {
+ bool initiator_can_access_entire_chain = true;
+ for (const GURL& url : url_chain) {
+ if (cors::OriginAccessList::AccessState::kAllowed !=
origin_access_list.CheckAccessState(request_initiator.value(), url)) {
- return true;
+ initiator_can_access_entire_chain = false;
+ break;
+ }
+ }
+ if (initiator_can_access_entire_chain) {
+ return true;
+ }
}
// Convert `site_for_cookies` into an origin (an opaque origin if
@@ -243,37 +259,41 @@
url::Origin site_origin =
url::Origin::Create(site_for_cookies.RepresentativeUrl());
- // If `site_for_cookies` represents an origin that is granted access to the
- // initiator and the target by `origin_access_list_` (typically such
- // `site_for_cookies` represents a Chrome Extension), then we also should
- // force ignoring of site for cookies if the initiator and the target are
- // same-site.
+ // If `site_for_cookies` represents an origin (typically a Chrome Extension),
+ // it can force ignoring of site for cookies only if `origin_access_list`
+ // grants it access to the initiator and every URL in `url_chain`. The
+ // initiator must also be same-site to every URL in the chain. All URLs must
+ // pass this path; permissions from the two paths must not be combined across
+ // redirect hops.
//
// Ideally we would walk up the frame tree and check that each ancestor is
- // first-party to the main frame (treating the `origin_access_list_`
+ // first-party to the main frame (treating the `origin_access_list`
// exceptions as "first-party"). But walking up the tree is not possible in
// //services/network and so we make do with just checking the direct
// initiator of the request.
//
- // We also check same-siteness between the initiator and the requested URL,
+ // We also check that the initiator is same-site to every URL in the chain,
// because setting `force_ignore_site_for_cookies` to true causes Strict
- // cookies to be attached, and having the initiator be same-site to the
- // request URL is a requirement for Strict cookies (see
+ // cookies to be attached, and this is a requirement for Strict cookies (see
// net::cookie_util::ComputeSameSiteContext).
if (!site_origin.opaque() && request_initiator.has_value()) {
- bool site_can_access_target =
- cors::OriginAccessList::AccessState::kAllowed ==
- origin_access_list.CheckAccessState(site_origin, url);
bool site_can_access_initiator =
cors::OriginAccessList::AccessState::kAllowed ==
origin_access_list.CheckAccessState(site_origin,
request_initiator->GetURL());
- net::SiteForCookies site_of_initiator =
- net::SiteForCookies::FromOrigin(request_initiator.value());
- bool are_initiator_and_target_same_site =
- site_of_initiator.IsFirstParty(url);
- if (site_can_access_initiator && site_can_access_target &&
- are_initiator_and_target_same_site) {
+ if (site_can_access_initiator) {
+ net::SiteForCookies site_of_initiator =
+ net::SiteForCookies::FromOrigin(request_initiator.value());
+ for (const GURL& url : url_chain) {
+ bool site_can_access_target =
+ cors::OriginAccessList::AccessState::kAllowed ==
+ origin_access_list.CheckAccessState(site_origin, url);
+ bool are_initiator_and_target_same_site =
+ site_of_initiator.IsFirstParty(url);
+ if (!site_can_access_target || !are_initiator_and_target_same_site) {
+ return false;
+ }
+ }
return true;
}
}
diff --git a/services/network/url_loader_util.h b/services/network/url_loader_util.h
index e5e1c9f4..09cc7b5 100644
--- a/services/network/url_loader_util.h
+++ b/services/network/url_loader_util.h
@@ -148,12 +148,13 @@
const std::string& devtools_request_id);
// Returns true if the site for cookies should be ignored to allow cookies to
-// be sent. This checks if the request initiator is granted access to the
-// target URL by the CORS origin access list, typically allowing extensions
-// to send cookies to cross-origin targets.
+// be sent. This applies the origin access and same-site checks to every URL in
+// `url_chain` using one consistent authorization path, typically allowing
+// extensions to send cookies to cross-origin targets. Returns false for an
+// empty chain.
COMPONENT_EXPORT(NETWORK_SERVICE)
bool ShouldForceIgnoreSiteForCookies(
- const GURL& url,
+ const std::vector<GURL>& url_chain,
const std::optional<url::Origin>& request_initiator,
const net::SiteForCookies& site_for_cookies,
const cors::OriginAccessList& origin_access_list);
diff --git a/chrome/browser/extensions/extension_cookies_browsertest.cc b/chrome/browser/extensions/extension_cookies_browsertest.cc
index 9c750d8..3707f5b8 100644
--- a/chrome/browser/extensions/extension_cookies_browsertest.cc
+++ b/chrome/browser/extensions/extension_cookies_browsertest.cc
@@ -165,10 +165,11 @@
// Tests for special handling of SameSite cookies for extensions:
// A request should be treated as same-site for the purposes of SameSite
// cookies if either
-// 1) the request initiator is an extension with access to the requested URL,
-// 2) the site_for_cookies is an extension with access to the requested URL,
-// and the request initiator (if it exists) is same-site to the requested
-// URL and also the extension has access to it.
+// 1) the request initiator is an extension with access to every URL in the
+// request's URL chain,
+// 2) the site_for_cookies is an extension with access to the initiator and
+// every URL in the request's URL chain, and the request initiator is
+// same-site to every URL in the chain.
// See url_loader_util::ShouldForceIgnoreSiteForCookies().
//
// The test fixture param is whether or not legacy SameSite semantics are
@@ -299,10 +300,12 @@
}
// Extension initiates a no-cors request to a permitted host that redirects to
-// a disallowed host => SameSite cookies are not sent on the redirected
-// request.
-IN_PROC_BROWSER_TEST_P(ExtensionSameSiteCookiesTest,
- ExtensionInitiatedNoCorsRedirectNotPermitted) {
+// a disallowed host => SameSite=Lax/Strict cookies are not sent on the
+// redirected request. A request must not retain the extension's SameSite
+// bypass after crossing an origin access boundary.
+IN_PROC_BROWSER_TEST_P(
+ ExtensionSameSiteCookiesTest,
+ ExtensionInitiatedNoCorsRedirectFromPermittedToNotPermitted) {
SetCookies(ExtensionCookiesTestHelper::kNotPermittedHost);
content::RenderFrameHost* frame = NavigateMainFrameToExtensionPage();
GURL target_url =
@@ -315,6 +318,25 @@
ExpectNoSameSiteCookies(cookies);
}
+// Extension initiates a no-cors request to a disallowed host that redirects to
+// a permitted host => SameSite=Lax/Strict cookies are not sent on the
+// redirected request. A request must not acquire the extension's SameSite
+// bypass mid-chain.
+IN_PROC_BROWSER_TEST_P(
+ ExtensionSameSiteCookiesTest,
+ ExtensionInitiatedNoCorsRedirectFromNotPermittedToPermitted) {
+ SetCookies(ExtensionCookiesTestHelper::kPermittedHost);
+ content::RenderFrameHost* frame = NavigateMainFrameToExtensionPage();
+ GURL target_url =
+ test_server()->GetURL(ExtensionCookiesTestHelper::kPermittedHost,
+ ExtensionCookiesTestHelper::kFetchCookiesPath);
+ GURL redirect_url =
+ test_server()->GetURL(ExtensionCookiesTestHelper::kNotPermittedHost,
+ "/server-redirect-307?" + target_url.spec());
+ std::string cookies = helper().FetchCookiesNoCors(frame, redirect_url);
+ ExpectNoSameSiteCookies(cookies);
+}
+
// Tests with one frame on an extension page which makes the request.
// Extension is site_for_cookies, initiator and requested URL are permitted,
diff --git a/services/network/network_context_unittest.cc b/services/network/network_context_unittest.cc
index 3912074..8e3522b 100644
--- a/services/network/network_context_unittest.cc
+++ b/services/network/network_context_unittest.cc
@@ -171,6 +171,7 @@
#include "services/network/net_log_exporter.h"
#include "services/network/network_qualities_pref_delegate.h"
#include "services/network/network_service.h"
+#include "services/network/network_service_network_delegate.h"
#include "services/network/public/cpp/constants.h"
#include "services/network/public/cpp/features.h"
#include "services/network/public/cpp/network_service_buildflags.h"
@@ -6518,6 +6519,38 @@
EXPECT_FALSE(state->IsDohProbeRunning());
}
+TEST_F(NetworkContextTest,
+ ShouldForceIgnoreSiteForCookiesCalledForEveryRedirectHop) {
+ std::vector<std::vector<GURL>> url_chains;
+ std::unique_ptr<NetworkContext> network_context =
+ CreateContextWithParams(CreateNetworkContextParamsForTesting());
+ auto* network_delegate = static_cast<NetworkServiceNetworkDelegate*>(
+ network_context->url_request_context()->network_delegate());
+ network_delegate->SetShouldForceIgnoreSiteForCookiesCallbackForTesting(
+ base::BindLambdaForTesting([&url_chains](const net::URLRequest& request) {
+ url_chains.push_back(request.url_chain());
+ }));
+
+ net::EmbeddedTestServer test_server;
+ test_server.AddDefaultHandlers(base::FilePath());
+ ASSERT_TRUE(test_server.Start());
+ const GURL target_url = test_server.GetURL("/echo");
+ const GURL redirect_url =
+ test_server.GetURL("/server-redirect-307?" + target_url.spec());
+
+ net::TestDelegate delegate;
+ std::unique_ptr<net::URLRequest> request =
+ network_context->url_request_context()->CreateRequest(
+ redirect_url, net::DEFAULT_PRIORITY, &delegate,
+ TRAFFIC_ANNOTATION_FOR_TESTS, net::handles::kInvalidNetworkHandle);
+ request->Start();
+ delegate.RunUntilComplete();
+
+ EXPECT_THAT(delegate.request_status(), net::test::IsOk());
+ EXPECT_THAT(url_chains, Contains(ElementsAre(redirect_url)));
+ EXPECT_THAT(url_chains, Contains(ElementsAre(redirect_url, target_url)));
+}
+
TEST_F(NetworkContextTest, PrivacyModeDisabledByDefault) {
const GURL kURL("http://foo.com");
const GURL kOtherURL("http://other.com");
diff --git a/services/network/url_loader_util_unittest.cc b/services/network/url_loader_util_unittest.cc
index 75a644fa..fc295667 100644
--- a/services/network/url_loader_util_unittest.cc
+++ b/services/network/url_loader_util_unittest.cc
@@ -192,9 +192,97 @@
// initiator and target, and they are same-site, so the delegate path
// should return true.
EXPECT_TRUE(ShouldForceIgnoreSiteForCookies(
- url_request->url(), url_request->initiator(),
+ url_request->url_chain(), url_request->initiator(),
url_request->site_for_cookies(), origin_access_list));
}
+TEST_F(ConfigureUrlRequestTest,
+ ForceIgnoreSiteForCookiesRequiresAccessToEntireUrlChain) {
+ url::ScopedSchemeRegistryForTests scoped_registry;
+ url::AddStandardScheme("chrome-extension", url::SCHEME_WITH_HOST);
+
+ const url::Origin extension_origin =
+ url::Origin::Create(GURL("chrome-extension://abc123"));
+ const GURL allowed_url("https://allowed.example/resource");
+ const GURL another_allowed_url("https://allowed.example/redirected");
+ const GURL disallowed_url("https://disallowed.example/redirect");
+
+ cors::OriginAccessList origin_access_list;
+ origin_access_list.AddAllowListEntryForOrigin(
+ extension_origin, "https", "allowed.example",
+ /*port=*/0, mojom::CorsDomainMatchMode::kDisallowSubdomains,
+ mojom::CorsPortMatchMode::kAllowAnyPort,
+ mojom::CorsOriginAccessMatchPriority::kDefaultPriority);
+
+ const net::SiteForCookies extension_site_for_cookies =
+ net::SiteForCookies::FromOrigin(extension_origin);
+ EXPECT_TRUE(ShouldForceIgnoreSiteForCookies({allowed_url}, extension_origin,
+ extension_site_for_cookies,
+ origin_access_list));
+ EXPECT_TRUE(ShouldForceIgnoreSiteForCookies(
+ {allowed_url, another_allowed_url}, extension_origin,
+ extension_site_for_cookies, origin_access_list));
+
+ // A redirect chain must not acquire the SameSite bypass when it enters the
+ // extension's allowed origins, or retain it after leaving them.
+ EXPECT_FALSE(ShouldForceIgnoreSiteForCookies(
+ {disallowed_url, allowed_url}, extension_origin,
+ extension_site_for_cookies, origin_access_list));
+ EXPECT_FALSE(ShouldForceIgnoreSiteForCookies(
+ {allowed_url, disallowed_url}, extension_origin,
+ extension_site_for_cookies, origin_access_list));
+ EXPECT_FALSE(ShouldForceIgnoreSiteForCookies(
+ {allowed_url, disallowed_url, another_allowed_url}, extension_origin,
+ extension_site_for_cookies, origin_access_list));
+ EXPECT_FALSE(ShouldForceIgnoreSiteForCookies(
+ {}, extension_origin, extension_site_for_cookies, origin_access_list));
+}
+
+TEST_F(ConfigureUrlRequestTest,
+ ForceIgnoreSiteForCookiesDoesNotCombineAuthorizationPaths) {
+ url::ScopedSchemeRegistryForTests scoped_registry;
+ url::AddStandardScheme("chrome-extension", url::SCHEME_WITH_HOST);
+
+ const url::Origin extension_origin =
+ url::Origin::Create(GURL("chrome-extension://abc123"));
+ const url::Origin web_origin =
+ url::Origin::Create(GURL("https://initiator.example"));
+ const GURL web_origin_allowed_url(
+ "https://web-origin-allowed.example/resource");
+ const GURL extension_allowed_url("https://sub.initiator.example/resource");
+ const GURL another_extension_allowed_url(
+ "https://other.initiator.example/redirected");
+
+ cors::OriginAccessList origin_access_list;
+ origin_access_list.AddAllowListEntryForOrigin(
+ web_origin, "https", "web-origin-allowed.example",
+ /*port=*/0, mojom::CorsDomainMatchMode::kDisallowSubdomains,
+ mojom::CorsPortMatchMode::kAllowAnyPort,
+ mojom::CorsOriginAccessMatchPriority::kDefaultPriority);
+ origin_access_list.AddAllowListEntryForOrigin(
+ extension_origin, "https", "initiator.example",
+ /*port=*/0, mojom::CorsDomainMatchMode::kAllowSubdomains,
... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/extensions/extension_cookies_browsertest.cc b/chrome/browser/extensions/extension_cookies_browsertest.cc
index 9c750d8..3707f5b8 100644
--- a/chrome/browser/extensions/extension_cookies_browsertest.cc
+++ b/chrome/browser/extensions/extension_cookies_browsertest.cc
@@ -165,10 +165,11 @@
// Tests for special handling of SameSite cookies for extensions:
// A request should be treated as same-site for the purposes of SameSite
// cookies if either
-// 1) the request initiator is an extension with access to the requested URL,
-// 2) the site_for_cookies is an extension with access to the requested URL,
-// and the request initiator (if it exists) is same-site to the requested
-// URL and also the extension has access to it.
+// 1) the request initiator is an extension with access to every URL in the
+// request's URL chain,
+// 2) the site_for_cookies is an extension with access to the initiator and
+// every URL in the request's URL chain, and the request initiator is
+// same-site to every URL in the chain.
// See url_loader_util::ShouldForceIgnoreSiteForCookies().
//
// The test fixture param is whether or not legacy SameSite semantics are
@@ -299,10 +300,12 @@
}
// Extension initiates a no-cors request to a permitted host that redirects to
-// a disallowed host => SameSite cookies are not sent on the redirected
-// request.
-IN_PROC_BROWSER_TEST_P(ExtensionSameSiteCookiesTest,
- ExtensionInitiatedNoCorsRedirectNotPermitted) {
+// a disallowed host => SameSite=Lax/Strict cookies are not sent on the
+// redirected request. A request must not retain the extension's SameSite
+// bypass after crossing an origin access boundary.
+IN_PROC_BROWSER_TEST_P(
+ ExtensionSameSiteCookiesTest,
+ ExtensionInitiatedNoCorsRedirectFromPermittedToNotPermitted) {
SetCookies(ExtensionCookiesTestHelper::kNotPermittedHost);
content::RenderFrameHost* frame = NavigateMainFrameToExtensionPage();
GURL target_url =
@@ -315,6 +318,25 @@
ExpectNoSameSiteCookies(cookies);
}
+// Extension initiates a no-cors request to a disallowed host that redirects to
+// a permitted host => SameSite=Lax/Strict cookies are not sent on the
+// redirected request. A request must not acquire the extension's SameSite
+// bypass mid-chain.
+IN_PROC_BROWSER_TEST_P(
+ ExtensionSameSiteCookiesTest,
+ ExtensionInitiatedNoCorsRedirectFromNotPermittedToPermitted) {
+ SetCookies(ExtensionCookiesTestHelper::kPermittedHost);
+ content::RenderFrameHost* frame = NavigateMainFrameToExtensionPage();
+ GURL target_url =
+ test_server()->GetURL(ExtensionCookiesTestHelper::kPermittedHost,
+ ExtensionCookiesTestHelper::kFetchCookiesPath);
+ GURL redirect_url =
+ test_server()->GetURL(ExtensionCookiesTestHelper::kNotPermittedHost,
+ "/server-redirect-307?" + target_url.spec());
+ std::string cookies = helper().FetchCookiesNoCors(frame, redirect_url);
+ ExpectNoSameSiteCookies(cookies);
+}
+
// Tests with one frame on an extension page which makes the request.
// Extension is site_for_cookies, initiator and requested URL are permitted,
diff --git a/services/network/network_context_unittest.cc b/services/network/network_context_unittest.cc
index 3912074..8e3522b 100644
--- a/services/network/network_context_unittest.cc
+++ b/services/network/network_context_unittest.cc
@@ -171,6 +171,7 @@
#include "services/network/net_log_exporter.h"
#include "services/network/network_qualities_pref_delegate.h"
#include "services/network/network_service.h"
+#include "services/network/network_service_network_delegate.h"
#include "services/network/public/cpp/constants.h"
#include "services/network/public/cpp/features.h"
#include "services/network/public/cpp/network_service_buildflags.h"
@@ -6518,6 +6519,38 @@
EXPECT_FALSE(state->IsDohProbeRunning());
}
+TEST_F(NetworkContextTest,
+ ShouldForceIgnoreSiteForCookiesCalledForEveryRedirectHop) {
+ std::vector<std::vector<GURL>> url_chains;
+ std::unique_ptr<NetworkContext> network_context =
+ CreateContextWithParams(CreateNetworkContextParamsForTesting());
+ auto* network_delegate = static_cast<NetworkServiceNetworkDelegate*>(
+ network_context->url_request_context()->network_delegate());
+ network_delegate->SetShouldForceIgnoreSiteForCookiesCallbackForTesting(
+ base::BindLambdaForTesting([&url_chains](const net::URLRequest& request) {
+ url_chains.push_back(request.url_chain());
+ }));
+
+ net::EmbeddedTestServer test_server;
+ test_server.AddDefaultHandlers(base::FilePath());
+ ASSERT_TRUE(test_server.Start());
+ const GURL target_url = test_server.GetURL("/echo");
+ const GURL redirect_url =
+ test_server.GetURL("/server-redirect-307?" + target_url.spec());
+
+ net::TestDelegate delegate;
+ std::unique_ptr<net::URLRequest> request =
+ network_context->url_request_context()->CreateRequest(
+ redirect_url, net::DEFAULT_PRIORITY, &delegate,
+ TRAFFIC_ANNOTATION_FOR_TESTS, net::handles::kInvalidNetworkHandle);
+ request->Start();
+ delegate.RunUntilComplete();
+
+ EXPECT_THAT(delegate.request_status(), net::test::IsOk());
+ EXPECT_THAT(url_chains, Contains(ElementsAre(redirect_url)));
+ EXPECT_THAT(url_chains, Contains(ElementsAre(redirect_url, target_url)));
+}
+
TEST_F(NetworkContextTest, PrivacyModeDisabledByDefault) {
const GURL kURL("http://foo.com");
const GURL kOtherURL("http://other.com");
diff --git a/services/network/url_loader_util_unittest.cc b/services/network/url_loader_util_unittest.cc
index 75a644fa..fc295667 100644
--- a/services/network/url_loader_util_unittest.cc
+++ b/services/network/url_loader_util_unittest.cc
@@ -192,9 +192,97 @@
// initiator and target, and they are same-site, so the delegate path
// should return true.
EXPECT_TRUE(ShouldForceIgnoreSiteForCookies(
- url_request->url(), url_request->initiator(),
+ url_request->url_chain(), url_request->initiator(),
url_request->site_for_cookies(), origin_access_list));
}
+TEST_F(ConfigureUrlRequestTest,
+ ForceIgnoreSiteForCookiesRequiresAccessToEntireUrlChain) {
+ url::ScopedSchemeRegistryForTests scoped_registry;
+ url::AddStandardScheme("chrome-extension", url::SCHEME_WITH_HOST);
+
+ const url::Origin extension_origin =
+ url::Origin::Create(GURL("chrome-extension://abc123"));
+ const GURL allowed_url("https://allowed.example/resource");
+ const GURL another_allowed_url("https://allowed.example/redirected");
+ const GURL disallowed_url("https://disallowed.example/redirect");
+
+ cors::OriginAccessList origin_access_list;
+ origin_access_list.AddAllowListEntryForOrigin(
+ extension_origin, "https", "allowed.example",
+ /*port=*/0, mojom::CorsDomainMatchMode::kDisallowSubdomains,
+ mojom::CorsPortMatchMode::kAllowAnyPort,
+ mojom::CorsOriginAccessMatchPriority::kDefaultPriority);
+
+ const net::SiteForCookies extension_site_for_cookies =
+ net::SiteForCookies::FromOrigin(extension_origin);
+ EXPECT_TRUE(ShouldForceIgnoreSiteForCookies({allowed_url}, extension_origin,
+ extension_site_for_cookies,
+ origin_access_list));
+ EXPECT_TRUE(ShouldForceIgnoreSiteForCookies(
+ {allowed_url, another_allowed_url}, extension_origin,
+ extension_site_for_cookies, origin_access_list));
+
+ // A redirect chain must not acquire the SameSite bypass when it enters the
+ // extension's allowed origins, or retain it after leaving them.
+ EXPECT_FALSE(ShouldForceIgnoreSiteForCookies(
+ {disallowed_url, allowed_url}, extension_origin,
+ extension_site_for_cookies, origin_access_list));
+ EXPECT_FALSE(ShouldForceIgnoreSiteForCookies(
+ {allowed_url, disallowed_url}, extension_origin,
+ extension_site_for_cookies, origin_access_list));
+ EXPECT_FALSE(ShouldForceIgnoreSiteForCookies(
+ {allowed_url, disallowed_url, another_allowed_url}, extension_origin,
+ extension_site_for_cookies, origin_access_list));
+ EXPECT_FALSE(ShouldForceIgnoreSiteForCookies(
+ {}, extension_origin, extension_site_for_cookies, origin_access_list));
+}
+
+TEST_F(ConfigureUrlRequestTest,
+ ForceIgnoreSiteForCookiesDoesNotCombineAuthorizationPaths) {
+ url::ScopedSchemeRegistryForTests scoped_registry;
+ url::AddStandardScheme("chrome-extension", url::SCHEME_WITH_HOST);
+
+ const url::Origin extension_origin =
+ url::Origin::Create(GURL("chrome-extension://abc123"));
+ const url::Origin web_origin =
+ url::Origin::Create(GURL("https://initiator.example"));
+ const GURL web_origin_allowed_url(
+ "https://web-origin-allowed.example/resource");
+ const GURL extension_allowed_url("https://sub.initiator.example/resource");
+ const GURL another_extension_allowed_url(
+ "https://other.initiator.example/redirected");
+
+ cors::OriginAccessList origin_access_list;
+ origin_access_list.AddAllowListEntryForOrigin(
+ web_origin, "https", "web-origin-allowed.example",
+ /*port=*/0, mojom::CorsDomainMatchMode::kDisallowSubdomains,
+ mojom::CorsPortMatchMode::kAllowAnyPort,
+ mojom::CorsOriginAccessMatchPriority::kDefaultPriority);
+ origin_access_list.AddAllowListEntryForOrigin(
+ extension_origin, "https", "initiator.example",
+ /*port=*/0, mojom::CorsDomainMatchMode::kAllowSubdomains,
+ mojom::CorsPortMatchMode::kAllowAnyPort,
+ mojom::CorsOriginAccessMatchPriority::kDefaultPriority);
+
+ const net::SiteForCookies extension_site_for_cookies =
+ net::SiteForCookies::FromOrigin(extension_origin);
+ EXPECT_TRUE(ShouldForceIgnoreSiteForCookies(
+ {web_origin_allowed_url}, web_origin, extension_site_for_cookies,
+ origin_access_list));
+ EXPECT_TRUE(ShouldForceIgnoreSiteForCookies(
+ {extension_allowed_url}, web_origin, extension_site_for_cookies,
+ origin_access_list));
+ EXPECT_TRUE(ShouldForceIgnoreSiteForCookies(
+ {extension_allowed_url, another_extension_allowed_url}, web_origin,
+ extension_site_for_cookies, origin_access_list));
+
+ // Each URL is allowed by one of the two authorization paths, but neither
+ // path covers the whole chain.
+ EXPECT_FALSE(ShouldForceIgnoreSiteForCookies(
+ {web_origin_allowed_url, extension_allowed_url}, web_origin,
+ extension_site_for_cookies, origin_access_list));
+}
+
} // namespace
} // namespace network::url_loader_util
Original Bug Report
SameSite cookie bypass on cross-site redirect into extension host permissions
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A per-hop ShouldForceIgnoreSiteForCookies check in the network delegate evaluates only the current-hop URL against an extension’s origin access list rather than validating the full redirect chain. A cross-site redirect from an untrusted endpoint to an origin within an extension’s host permissions causes the request to be granted first-party cookie privilege mid-chain. This allows SameSite=Strict and Lax cookies to be attached and accepted across cross-site redirects, potentially enabling CSRF and session fixation.
Affected files:
services/network/network_service_network_delegate.ccservices/network/url_loader_util.ccnet/cookies/cookie_util.ccnet/url_request/url_request_http_job.ccservices/network/cors/cors_url_loader.cc
Estimated timestamp from git blame: 2026-05-06
Summary
When a network request initiated by an extension undergoes an HTTP redirect, NetworkServiceNetworkDelegate::OnShouldForceIgnoreSiteForCookies re-evaluates whether the request should be treated as first-party by inspecting only the destination URL of the current hop (request.url()). Because request.url_chain() is not validated, a cross-site redirect from an attacker-controlled endpoint into an origin covered by the extension’s host_permissions acquires force_ignore_site_for_cookies = true mid-chain, attaching SameSite=Strict and SameSite=Lax cookies to an attacker-steered request.
Technical Details
- Per-Hop Privilege Evaluation:
In
net/url_request/url_request_http_job.cc(lines 806-807, 1033-1034),ShouldForceIgnoreSiteForCookies(*request_)is queried on every request hop:
// services/network/network_service_network_delegate.cc:298-303
bool NetworkServiceNetworkDelegate::OnShouldForceIgnoreSiteForCookies(
const net::URLRequest& request) {
return url_loader_util::ShouldForceIgnoreSiteForCookies(
request.url(), request.initiator(), request.site_for_cookies(),
network_context_->cors_origin_access_list());
}
- Stateless Origin Access Check:
In
services/network/url_loader_util.cc(lines 230-234):
if (request_initiator.has_value() &&
cors::OriginAccessList::AccessState::kAllowed ==
origin_access_list.CheckAccessState(request_initiator.value(), url)) {
return true;
}
This check evaluates whether the extension (request_initiator) has access to the current hop’s url. It does not check whether previous hops in request.url_chain() passed through untrusted/cross-site origins.
- Short-Circuiting of SameSite Downgrade Logic:
When
force_ignore_site_for_cookiesistrue,net/cookies/cookie_util.ccimmediately short-circuits SameSite calculation:
// net/cookies/cookie_util.cc:946-947 (request)
if (force_ignore_site_for_cookies)
return CookieOptions::SameSiteCookieContext::MakeInclusive();
// net/cookies/cookie_util.cc:997-998 (response)
if (force_ignore_site_for_cookies)
return CookieOptions::SameSiteCookieContext::MakeInclusiveForSet();
This bypasses the standard redirect-chain inspection in ComputeSameSiteContext (net/cookies/cookie_util.cc:227-234), which is designed to downgrade SameSite context across cross-site redirect hops.
- No-Cors Redirect Handling:
For requests with
mode: 'no-cors',CorsURLLoader::FollowRedirectdoes not block cross-origin redirects and continues the samenet::URLRequest(services/network/cors/cors_url_loader.cc:582-596).site_for_cookies_remainschrome-extension://<id>, bypassing third-party cookie blocking viakAllowByScheme(chrome/browser/net/profile_network_context_service.cc:1149-1150).
Potential Attack Steps
(Note: These are potential steps based on code analysis; our tooling agent does not currently run live exploit code.)
- A victim user has installed an extension with host permissions covering
https://victim.example/*(or<all_urls>). - The extension initiates a credentialed
no-corssubresource fetch (e.g., rendering<img src=...>for an external feed/avatar, or callingfetch(url, {mode: 'no-cors', credentials: 'include'})) targeting an attacker-controlled endpointhttps://attacker.example/r. - The attacker’s server responds with
HTTP 307 Temporary Redirect(preserving POST body/method) orHTTP 302 FoundwithLocation: https://victim.example/transfer?to=attacker&amount=1000. - On the second hop, the network delegate evaluates
victim.exampleagainst the extension’s origin access list, returningtrueforShouldForceIgnoreSiteForCookies. - The browser attaches the user’s
SameSite=StrictandSameSite=Laxcookies forvictim.exampleto the attacker-steered request, executing a blind CSRF request. - For endpoints returning
Set-Cookie,MakeInclusiveForSet()allows settingSameSite=Strictsession cookies on the steered response.
Mitigating Factors
- The request arrives with
Sec-Fetch-Site: cross-site, so servers enforcingSec-Fetch-Sitevalidation are not vulnerable. - Response bodies for
no-corsrequests remain opaque, preventing direct response data exfiltration.
Suggested Fix
Ensure that ShouldForceIgnoreSiteForCookies requires all hops in request.url_chain() (or at minimum both the initial initiator/URL and destination URL) to be covered by the extension’s origin access list before granting inclusive SameSite cookie status.
Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c
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.