CVE-2026-87447
Overview
What the public commits show
e527dc0c98c5 ("[Extensions] Verify that no-cors redirects don’t leak SameSite cookies"), adds browser tests that lock in the corrected behaviour. The code change that fixed the behaviour does not cite this bug.
These commits cite this CVE's bug, but none of them contains a code change that removes the vulnerability, so no root-cause analysis is published. If the code fix later becomes public (or is identified under another bug), it will be analyzed then.
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_test_helper.ccchrome/browser/extensions/extension_cookies_test_helper.hchrome/browser/extensions/extension_cookies_browsertest.cc
Patch
From e527dc0c98c520274dfb1444fca33e67c31d96c9 Mon Sep 17 00:00:00 2001
From: Mike West <mkwst@chromium.org>
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 <reillyg@chromium.org>
Commit-Queue: Charlie Reis <creis@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1676728}
---
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).
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,
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.