Medium watch CVSS 6.5 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in DeviceBoundSessionCredentials
DescriptionIncorrect authorization in DeviceBoundSessionCredentials
ComponentDeviceBoundSessionCredentials
Sub-componentBlink>SecurityFeature>DeviceBoundSessionCredentials
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker553155590
Fix commitc25b678887fd (chromium/src) +219/-37
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-08-26 (per vendor advisory)
Fix landed2026-08-28 (public git)
Disclosed2026-09-08
Patch gap11 days — fix public in source before the advisory

Background

`ShouldForceIgnoreSiteForCookies`
A network::url_loader_util helper 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_initiator or site_for_cookies) same-origin-style access to specific hosts, queried via CheckAccessState.
`url_chain()`
A net::URLRequest’s ordered list of every URL it has visited across redirect hops, whereas url() 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.

Key insight
The single mistake is authorizing the SameSite bypass against only the current 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

  1. 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’s kNotPermittedHost / disallowed.example).
  2. 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 a url_chain of [disallowed, permitted].
  3. Bypass acquired mid-chain OnShouldForceIgnoreSiteForCookies is re-queried after the redirect with request.url() equal to the permitted host; pre-patch ShouldForceIgnoreSiteForCookies inspects only that URL, the initiator-access check passes, and it returns true.
  4. Strict/Lax cookies sent across an origin-access boundary force_ignore_site_for_cookies is 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.
  5. 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

An attacker able to control a redirect within an extension-initiated request chain can cause SameSite=Strict/Lax cookies to be attached to a cross-site request to a permitted host, bypassing SameSite cookie protections within the network service. The precondition is an extension whose 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

FunctionChangeNotes
if
services/network/network_service_network_delegate.cc
modified
for
services/network/url_loader_util.cc
modified
if
services/network/url_loader_util.cc
modified
BindLambdaForTesting
services/network/network_context_unittest.cc
modified
TEST_F
services/network/network_context_unittest.cc
modified

Files Changed

  • services/network/url_loader_util_unittest.cc
  • services/network/network_context_unittest.cc
  • services/network/url_loader_util.cc
  • services/network/network_service_network_delegate.h
  • services/network/url_loader_util.h
  • services/network/network_service_network_delegate.cc
  • chrome/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 consult url_chain() and require every hop to pass.
  • Bypass flags gated on origin access
    Review other places that set request bypass flags such as force_ignore_site_for_cookies from OriginAccessList::CheckAccessState, ensuring the grant covers the whole request rather than one URL.
  • Combinable authorization branches
    Look 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.
From c25b678887fda4fd84d0041857115d6ca6c1c32f Mon Sep 17 00:00:00 2001
From: Ho Cheung <[email protected]>
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 <[email protected]>
Reviewed-by: James Cook <[email protected]>
Commit-Queue: Aaron Teo <[email protected]>
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • services/network/url_loader_util.cc
  • net/cookies/cookie_util.cc
  • net/url_request/url_request_http_job.cc
  • services/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

  1. 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());
}
  1. 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.

  1. Short-Circuiting of SameSite Downgrade Logic: When force_ignore_site_for_cookies is true, net/cookies/cookie_util.cc immediately 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.

  1. No-Cors Redirect Handling: For requests with mode: 'no-cors', CorsURLLoader::FollowRedirect does not block cross-origin redirects and continues the same net::URLRequest (services/network/cors/cors_url_loader.cc:582-596). site_for_cookies_ remains chrome-extension://<id>, bypassing third-party cookie blocking via kAllowByScheme (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.)

  1. A victim user has installed an extension with host permissions covering https://victim.example/* (or <all_urls>).
  2. The extension initiates a credentialed no-cors subresource fetch (e.g., rendering <img src=...> for an external feed/avatar, or calling fetch(url, {mode: 'no-cors', credentials: 'include'})) targeting an attacker-controlled endpoint https://attacker.example/r.
  3. The attacker’s server responds with HTTP 307 Temporary Redirect (preserving POST body/method) or HTTP 302 Found with Location: https://victim.example/transfer?to=attacker&amount=1000.
  4. On the second hop, the network delegate evaluates victim.example against the extension’s origin access list, returning true for ShouldForceIgnoreSiteForCookies.
  5. The browser attaches the user’s SameSite=Strict and SameSite=Lax cookies for victim.example to the attacker-steered request, executing a blind CSRF request.
  6. For endpoints returning Set-Cookie, MakeInclusiveForSet() allows setting SameSite=Strict session cookies on the steered response.

Mitigating Factors

  • The request arrives with Sec-Fetch-Site: cross-site, so servers enforcing Sec-Fetch-Site validation are not vulnerable.
  • Response bodies for no-cors requests 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.

View on issue tracker