CVE-2026-87447
Overview
Background
- `SameSite` cookie
- A cookie attribute (e.g.
SameSite=Strict) that instructs the browser to withhold the cookie from cross-site requests to prevent cross-site data leakage. - `no-cors` fetch mode
- A
fetch()request mode that permits a limited cross-origin request whose response is opaque, still attaching credentials whencredentials: 'include'is set. - Extension host permissions
- The
manifest-declared set of hosts an extension is authorized to access, which governs whether extension-initiated requests are treated as same-site with those hosts. - HTTP redirect (`307`)
- A server response that instructs the client to re-issue the request to a new URL, at which point the browser must re-evaluate cookie and permission policy for the redirected hop.
Root Cause Analysis
no-cors request that began at a permitted host and then followed a server 307 redirect to another host; the browser had to decide whether SameSite=Strict cookies for the redirect target were eligible to be attached. Prior to crrev.com/c0edd277235be, the network stack failed to re-derive the site-for-cookies / initiator relationship correctly across the redirect hop, so an extension could reach a host it did not hold permissions for and still have SameSite=Strict cookies leaked onto the request, violating the invariant that SameSite=Strict cookies are sent only to targets same-site with a permitted initiating context. That earlier CL fixed the authorization decision so the redirect target’s cookie eligibility is recomputed against the extension’s actual host permissions. This commit does not itself change production logic; it is a TEST-ONLY change that locks in the corrected behavior. It adds two browser tests exercising the permitted-to-permitted and permitted-to-disallowed redirect cases and asserting that SameSite cookies are respectively sent and withheld. The tests work because they observe the actual Cookie header on the wire, proving the fix’s authorization boundary holds across redirects.no-cors, credentialed extension request’s SameSite cookie eligibility was not re-evaluated against the extension’s host permissions after a cross-host redirect, allowing SameSite=Strict cookies to leak to unpermitted hosts. The corrective CL re-computes the authorization on the redirected hop, and this commit adds regression tests that assert cookies are attached only when the redirect target is a permitted host.Attack Path
- Set up a permitted entry host
A malicious or compromised extension holds host permissions for one host but not for a target host holding
SameSite=Strictcookies. - Issue a credentialed `no-cors` fetch
From an extension page, the extension calls
fetch(url, {mode: 'no-cors', credentials: 'include'})against a URL on its permitted host. - Follow a redirect to the target
The permitted host returns a
307redirect (via/server-redirect-307) pointing at the unpermitted host that owns the sensitive cookies. - Leak `SameSite` cookies on the redirected hop
In the pre-fix behavior, the browser attached the target’s
SameSite=Strictcookies to the redirected request even though the extension lacked permission for that host. - Observe or exfiltrate
Although the
no-corsresponse is opaque to script, the credentialed request itself reaches the unpermitted host carrying cookies that should never have crossed the site boundary.
Impact Assessment
SameSite=Strict on hosts for which the extension lacks host permissions, causing a browser process to emit those cookies on the network to an unauthorized host. This is an incorrect-authorization / cross-site cookie-leak issue rooted in the network stack’s handling of extension-initiated no-cors redirects. The precondition is that a user has installed an extension able to issue such a request; note the leak was already closed by the earlier production CL, and this commit only adds regression coverage.Changed Functions
| Function | Change | Notes |
|---|---|---|
GURLchrome/browser/extensions/extension_cookies_test_helper.h |
modified | |
Profilechrome/browser/extensions/extension_cookies_test_helper.h |
modified |
Files Changed
chrome/browser/extensions/extension_cookies_browsertest.ccchrome/browser/extensions/extension_cookies_test_helper.ccchrome/browser/extensions/extension_cookies_test_helper.h
Audit Directions
- Redirect re-authorizationVerify that every credentialed request type re-derives
SameSite/ site-for-cookies and host-permission decisions on each redirect hop, not only on the initial request. - `no-cors` credentialed extension requestsAudit extension-initiated
no-corsfetches that carry credentials for paths where an opaque response can still leak cookies onto the wire to unpermitted hosts. - Test-coverage gapsWhere a production fix landed without regression tests, add wire-level assertions on the
Cookieheader (asFetchCookiesNoCorsdoes) so authorization boundaries stay enforced.
Patch
From e527dc0c98c520274dfb1444fca33e67c31d96c9 Mon Sep 17 00:00:00 2001 From: Mike West <[email protected]> Date: Mon, 10 Aug 2026 14:33:59 -0700 Subject: [PATCH] [Extensions] Verify that `no-cors` redirects don't leak `SameSite` cookies. Prior to https://crrev.com/c0edd277235be, it was possible for extensions to bypass `SameSite=Strict` without host permissions. That CL accidentally fixed this issue, which is excellent! But we should lock in the behavior with tests, which this CL provides. Bug: 503464711 Change-Id: I15be2ffbbe2daf180644b3524f247542acd5f8d3 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8136859 Reviewed-by: Reilly Grant <[email protected]> Commit-Queue: Charlie Reis <[email protected]> Cr-Commit-Position: refs/heads/main@{#1676728} --- diff --git a/chrome/browser/extensions/extension_cookies_browsertest.cc b/chrome/browser/extensions/extension_cookies_browsertest.cc index d0a577d..9c750d8 100644 --- a/chrome/browser/extensions/extension_cookies_browsertest.cc +++ b/chrome/browser/extensions/extension_cookies_browsertest.cc @@ -281,6 +281,40 @@ ExpectNoSameSiteCookies(cookies); } +// Extension initiates a no-cors request to a permitted host that redirects to +// another permitted host => SameSite cookies are sent on the redirected +// request. +IN_PROC_BROWSER_TEST_P(ExtensionSameSiteCookiesTest, + ExtensionInitiatedNoCorsRedirectPermitted) { + SetCookies(ExtensionCookiesTestHelper::kOtherPermittedHost); + content::RenderFrameHost* frame = NavigateMainFrameToExtensionPage(); + GURL target_url = + test_server()->GetURL(ExtensionCookiesTestHelper::kOtherPermittedHost, + ExtensionCookiesTestHelper::kFetchCookiesPath); + GURL redirect_url = + test_server()->GetURL(ExtensionCookiesTestHelper::kPermittedHost, + "/server-redirect-307?" + target_url.spec()); + std::string cookies = helper().FetchCookiesNoCors(frame, redirect_url); + ExpectSameSiteCookies(cookies); +} + +// 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) { + SetCookies(ExtensionCookiesTestHelper::kNotPermittedHost); + content::RenderFrameHost* frame = NavigateMainFrameToExtensionPage(); + GURL target_url = + test_server()->GetURL(ExtensionCookiesTestHelper::kNotPermittedHost, + ExtensionCookiesTestHelper::kFetchCookiesPath); + GURL redirect_url = + test_server()->GetURL(ExtensionCookiesTestHelper::kPermittedHost, + "/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/chrome/browser/extensions/extension_cookies_test_helper.cc b/chrome/browser/extensions/extension_cookies_test_helper.cc index e51d2b6..4291152 100644 --- a/chrome/browser/extensions/extension_cookies_test_helper.cc +++ b/chrome/browser/extensions/extension_cookies_test_helper.cc @@ -109,6 +109,42 @@ return result; } +std::string ExtensionCookiesTestHelper::FetchCookiesNoCors( + content::RenderFrameHost* frame, + const GURL& url) { + const char kScriptTemplate[] = R"( + fetch($1, {method: 'GET', mode: 'no-cors', credentials: 'include'}) + .then(() => window.domAutomationController.send('done')) + .catch((err) => window.domAutomationController.send('err: ' + err));)"; + content::DOMMessageQueue messages(frame); + content::ExecuteScriptAsync(frame, + content::JsReplace(kScriptTemplate, url.spec())); + + net::test_server::ControllableHttpResponse& http_response = + GetNextCookieResponse(); + http_response.WaitForRequest(); + + std::string cookie_header; + auto it = http_response.http_request()->headers.find( + net::HttpRequestHeaders::kCookie); + if (it != http_response.http_request()->headers.end()) { + cookie_header = it->second; + } + + http_response.Send( + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain; charset=utf-8\r\n" + "Content-Length: 0\r\n" + "\r\n"); + http_response.Done(); + + std::string result; + if (!messages.PopMessage(&result)) { + EXPECT_TRUE(messages.WaitForMessage(&result)); + } + return cookie_header; +} + std::string ExtensionCookiesTestHelper::NavigateChildAndGetCookies( content::RenderFrameHost* frame, const std::string& host) { diff --git a/chrome/browser/extensions/extension_cookies_test_helper.h b/chrome/browser/extensions/extension_cookies_test_helper.h index 86dd9a01..59dcb07e 100644 --- a/chrome/browser/extensions/extension_cookies_test_helper.h +++ b/chrome/browser/extensions/extension_cookies_test_helper.h @@ -11,6 +11,7 @@ #include "base/memory/raw_ref.h" +class GURL; class Profile; namespace content { @@ -91,6 +92,13 @@ std::string FetchCookies(content::RenderFrameHost* frame, const std::string& host); + // Issues a `mode: 'no-cors'` fetch against `url` from `frame`. `url` must + // resolve (possibly via redirects) to `kFetchCookiesPath` on this test + // server. Returns the Cookie header observed on the wire by consuming the + // next slot in the response pool. + std::string FetchCookiesNoCors(content::RenderFrameHost* frame, + const GURL& url); + // Triggers a `frame`-initiated navigation of `frame` to `host`. Returns // the cookies that were sent on that navigation request (read from the // navigated child's body).
Regression Test / PoC
diff --git a/chrome/browser/extensions/extension_cookies_browsertest.cc b/chrome/browser/extensions/extension_cookies_browsertest.cc
index d0a577d..9c750d8 100644
--- a/chrome/browser/extensions/extension_cookies_browsertest.cc
+++ b/chrome/browser/extensions/extension_cookies_browsertest.cc
@@ -281,6 +281,40 @@
ExpectNoSameSiteCookies(cookies);
}
+// Extension initiates a no-cors request to a permitted host that redirects to
+// another permitted host => SameSite cookies are sent on the redirected
+// request.
+IN_PROC_BROWSER_TEST_P(ExtensionSameSiteCookiesTest,
+ ExtensionInitiatedNoCorsRedirectPermitted) {
+ SetCookies(ExtensionCookiesTestHelper::kOtherPermittedHost);
+ content::RenderFrameHost* frame = NavigateMainFrameToExtensionPage();
+ GURL target_url =
+ test_server()->GetURL(ExtensionCookiesTestHelper::kOtherPermittedHost,
+ ExtensionCookiesTestHelper::kFetchCookiesPath);
+ GURL redirect_url =
+ test_server()->GetURL(ExtensionCookiesTestHelper::kPermittedHost,
+ "/server-redirect-307?" + target_url.spec());
+ std::string cookies = helper().FetchCookiesNoCors(frame, redirect_url);
+ ExpectSameSiteCookies(cookies);
+}
+
+// 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) {
+ SetCookies(ExtensionCookiesTestHelper::kNotPermittedHost);
+ content::RenderFrameHost* frame = NavigateMainFrameToExtensionPage();
+ GURL target_url =
+ test_server()->GetURL(ExtensionCookiesTestHelper::kNotPermittedHost,
+ ExtensionCookiesTestHelper::kFetchCookiesPath);
+ GURL redirect_url =
+ test_server()->GetURL(ExtensionCookiesTestHelper::kPermittedHost,
+ "/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,
Original Bug Report
SameSite CSRF via stale force_ignore_site_for_cookies flag on no-cors redirects
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. Please see go/chrome-ai-generated-security-bugs-faq for more information.
Overview: A logic flaw in the network service may allow a malicious extension to bypass its declared host_permissions and SameSite cookie protections. By initiating a no-cors request to a permitted origin that redirects to an arbitrary target, a permission flag persists across the redirect. This results in a potential CSRF primitive where SameSite=Strict and SameSite=Lax cookies are inappropriately attached to the cross-origin request.
Affected files:
services/network/url_loader_util.ccnet/url_request/url_request.ccservices/network/cors/cors_url_loader.ccnet/url_request/url_request_http_job.ccservices/network/url_loader.cc
Estimated timestamp from git blame: 2026-03-05
Summary
There is a potential vulnerability in how the Network Service handles redirects for no-cors requests initiated by Chrome extensions. The force_ignore_site_for_cookies flag, which grants extensions the ability to attach first-party cookies to requests targeting origins they have explicit permissions for, is not cleared during a redirect. If a no-cors request redirects to a cross-origin domain, the stale flag forces the network stack to compute an inclusive SAME_SITE_STRICT cookie context. This allows an extension with permissions to a single attacker-controlled domain to execute cross-origin POST requests with the victim’s SameSite=Strict and SameSite=Lax cookies attached.
Technical Details
- Flag Initialization: When a
network::URLLoaderis created,url_loader_util::ConfigureUrlRequest()evaluates whether the request initiator has privileges for the target URL by checking thecors::OriginAccessList. If allowed (e.g., the URL matches the extension’shost_permissions), it setsrequest->set_force_ignore_site_for_cookies(true)on the underlyingnet::URLRequest. - Redirect Handling in CORS Loader: If the target server responds with a redirect,
network::CorsURLLoader::FollowRedirect()processes it. For requests withmode: 'cors', it may restart the request, which re-evaluates permissions. However, formode: 'no-cors', it bypasses the manual restart logic and directly callsnetwork_loader_->FollowRedirect(). - Stale State Persistence: This delegates the redirect down to
net::URLRequest::Redirect(), which callsPrepareToRestart().PrepareToRestart()clears various state fields (likejob_andresponse_info_), but it does not clear theforce_ignore_site_for_cookies_flag. TheURLRequestobject retains this privileged state even though the destination URL has changed. - SameSite Bypass: A new
URLRequestHttpJobis created for the redirected URL. It reads the staleforce_ignore_site_for_cookies_flag and passes it tocookie_util::ComputeSameSiteContextForRequest(). This function sees thetrueflag and immediately returnsCookieOptions::SameSiteCookieContext::MakeInclusive(), establishing aSAME_SITE_STRICTcontext. TheCookieStoresubsequently attaches all of the victim’sSameSitecookies to the cross-origin request.
Potential Reproduction Steps
Note: Our tooling agent does not have the ability to run live code, so these are potential steps based on static analysis.
- An attacker publishes an extension with limited host permissions:
host_permissions: ["https://attacker.example/*"]. - The extension executes a
fetchrequest withmode: 'no-cors'andcredentials: 'include', sending a POST payload to its permitted origin:fetch('https://attacker.example/r', { mode: 'no-cors', method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'action=transfer&amount=1000' }); - The server at
attacker.example/rresponds with a307 Temporary Redirect(which preserves the POST method and body) to the victim endpoint:Location: https://victim.example/transfer. - The browser automatically follows the redirect. Because of the stale
force_ignore_site_for_cookiesflag, the resulting request tovictim.examplewill contain the user’sSameSite=StrictandSameSite=Laxcookies, successfully executing a CSRF attack despite the extension lacking permissions forvictim.example.
Suggested Fix
The force_ignore_site_for_cookies state must be correctly scoped to the specific URL hop, not the lifetime of the URLRequest.
- Option 1 (Network Service Layer): In
network::URLLoader::FollowRedirect(), explicitly re-evaluate the permission by callingurl_loader_util::ShouldForceIgnoreSiteForCookiesagainst the new target URL and updating theURLRequestaccordingly. - Option 2 (Net Layer): Clear
force_ignore_site_for_cookies_(set it tofalse) withinnet::URLRequest::PrepareToRestart(). This ensures that any privileges granted to a specific request hop are completely stripped before the redirect is followed, requiring higher layers (like the Network Service) to explicitly re-grant them if applicable.
Evaluated with Chrome root at commit: 661452647ddb2827305122ff3273bd5dea403f09
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.