CVE-2025-9866
Overview
Background
- `IsSafeRedirectTarget`
- the browser-side check that
NavigationURLLoaderImplapplies to every navigation redirect; it rejects unsafe targets such asdata:URLs withnet::ERR_UNSAFE_REDIRECT. - `WebRequestProxyingURLLoaderFactory`
- the extensions layer that sits in front of network requests when an extension uses
chrome.webRequestwith blocking listeners, so the extension can observe, block or redirect requests (including issuing its own redirects). - `bypass_redirect_checks_`
- a member of
NavigationURLLoaderImplthat, when set, skipsIsSafeRedirectTargetfor the whole navigation loader. How it is initialized is not shown in this diff; the regression test indicates it is set when a webRequest proxy is in the request path. - `URLResponseHead::bypass_redirect_checks`
- a new per-response Mojo field (default
false) through which the proxy says “this specific redirect was generated by an extension, so I vouch for it”.
Root Cause Analysis
Before the patch, the redirect handler in NavigationURLLoaderImpl (the hunk logs Navigation.QueueTime.OnReceiveRedirect; the enclosing function signature is not shown) evaluated if (!bypass_redirect_checks_ && !IsSafeRedirectTarget(url_, redirect_info.new_url)). Because of this, once a navigation loader was marked to bypass checks, every redirect on it skipped the safe-target check, not only the redirects the extension itself created. The regression test shows the consequence: with an MV3 extension proxying main_frame requests through a no-op blocking onBeforeRequest listener, a service worker answering the navigation with Response.redirect('data:text/html,...') succeeded (net::OK) when the new feature was off. The invariant violated is that the safe-redirect exemption should cover only redirects whose target came from the trusted extension layer, not redirects from page-controlled sources such as a service worker.
The fix adds bypass_redirect_checks to URLResponseHead, sets it to true only on extension-originated redirects in web_request_proxying_url_loader_factory.cc (when redirect_url_ == redirect_info.new_url, on the synthesized-redirect response head, and on the override_headers_ Location redirect), and, under kBypassRedirectChecksPerRequest (enabled by default), makes NavigationURLLoaderImpl read the per-response flag instead of the per-loader one. With the feature on, the service-worker data: redirect now fails with net::ERR_UNSAFE_REDIRECT, so the patch tightens the check.
URLResponseHead, and only the proxy sets it, and only for redirects the extension itself issued.Attack Path
- Precondition: proxying extension
The victim has an extension installed that registers a blocking
chrome.webRequestlistener formain_framerequests (the test uses a no-oponBeforeRequestreturning{}), so navigations go throughWebRequestProxyingURLLoaderFactory. - Register a redirecting service worker
An attacker-controlled site registers a service worker whose
fetchhandler responds withResponse.redirect()to adata:URL (service_worker_data_redirect.js). - Navigate into the worker's scope
When the victim navigates to a URL the worker controls, the worker’s redirect reaches the navigation loader’s redirect handler, and because
bypass_redirect_checks_was set for the loader,IsSafeRedirectTargetwas skipped. - Unsafe navigation commits
The navigation to the
data:URL succeeds (net::OKin the feature-off test), even though such a redirect should be blocked withnet::ERR_UNSAFE_REDIRECT. Using this for spoofing or phishing content at the top level is inferred.
Impact Assessment
data: URL from a service worker), but only for users who have an extension proxying requests through blocking webRequest. The effect is a security-policy bypass in the browser’s navigation logic, not memory corruption; downstream uses such as content spoofing or reaching other redirect-blocked schemes are inferred. The advisory rates the issue as medium (“Inappropriate implementation in Extensions”) and does not report exploitation in the wild.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifextensions/browser/api/web_request/web_request_proxying_url_loader_factory.cc |
modified | |
ManifestV3WebRequestApiTestWithBypassRedirectChecksPerRequestchrome/browser/extensions/api/web_request/web_request_apitest.cc |
modified | |
ifchrome/browser/extensions/api/web_request/web_request_apitest.cc |
modified | |
OnAuthRequiredApiTestchrome/browser/extensions/api/web_request/web_request_apitest.cc |
modified |
Files Changed
chrome/browser/extensions/api/web_request/web_request_apitest.ccchrome/test/data/service_worker/service_worker_data_redirect.jschrome/test/data/service_worker/service_worker_setup_data_redirect.htmlcontent/browser/loader/navigation_url_loader_impl.cccontent/public/common/content_features.cccontent/public/common/content_features.hextensions/browser/api/web_request/web_request_proxying_url_loader_factory.ccservices/network/public/mojom/url_response_head.mojom
Audit Directions
- Coarse-grained trust flagsLook for loader- or frame-wide booleans (bypass, trusted, privileged,
is_*_initiated) that are set because one trusted component is in the chain, and then exempt all traffic in that chain, including traffic from page-controlled sources such as service workers,fetchinterceptors or prefetch. - Interceptors ahead of proxiesNavigation interceptors (service workers, prefetch/prerender caches, Web Bundles) can produce redirects that never pass through
WebRequestProxyingURLLoaderFactory; check every place that skips its own validation on the assumption that the proxy already did it. - Feature-gated fallbacksWhile
kBypassRedirectChecksPerRequestexists, the old per-loader path can be re-enabled through field trials or policy; also check non-navigation loaders (subresources, workers) for an equivalentbypass_redirect_checks_pattern that was not converted.
Patch
diff --git a/content/browser/loader/navigation_url_loader_impl.cc b/content/browser/loader/navigation_url_loader_impl.cc
index 6fb346b..af25aee 100644
--- a/content/browser/loader/navigation_url_loader_impl.cc
+++ b/content/browser/loader/navigation_url_loader_impl.cc
@@ -72,6 +72,7 @@
#include "content/public/browser/url_loader_throttles.h"
#include "content/public/browser/web_ui_url_loader_factory.h"
#include "content/public/common/content_client.h"
+#include "content/public/common/content_features.h"
#include "content/public/common/content_switches.h"
#include "content/public/common/referrer.h"
#include "content/public/common/url_constants.h"
@@ -1263,7 +1264,13 @@
LogQueueTimeHistogram("Navigation.QueueTime.OnReceiveRedirect",
resource_request_->is_outermost_main_frame);
net::Error error = net::OK;
- if (!bypass_redirect_checks_ &&
+
+ bool bypass_redirect_checks =
+ base::FeatureList::IsEnabled(features::kBypassRedirectChecksPerRequest)
+ ? head->bypass_redirect_checks
+ : bypass_redirect_checks_;
+
+ if (!bypass_redirect_checks &&
!IsSafeRedirectTarget(url_, redirect_info.new_url)) {
error = net::ERR_UNSAFE_REDIRECT;
} else if (--redirect_limit_ == 0) {
diff --git a/content/public/common/content_features.cc b/content/public/common/content_features.cc
index 60d0d0c4..dd32364b 100644
--- a/content/public/common/content_features.cc
+++ b/content/public/common/content_features.cc
@@ -184,6 +184,12 @@
"BrokerFileOperationsOnDiskCacheInNetworkService",
base::FEATURE_DISABLED_BY_DEFAULT);
+// Allows the decision to bypass redirect checks to be made based on the
+// specific request.
+BASE_FEATURE(kBypassRedirectChecksPerRequest,
+ "BypassRedirectChecksPerRequest",
+ base::FEATURE_ENABLED_BY_DEFAULT);
+
// Allows pages with cache-control:no-store to enter the back/forward cache.
// Feature params can specify whether pages with cache-control:no-store can be
// restored if cookies change / if HTTPOnly cookies change.
diff --git a/content/public/common/content_features.h b/content/public/common/content_features.h
index f53fa32..fa304b8c 100644
--- a/content/public/common/content_features.h
+++ b/content/public/common/content_features.h
@@ -63,6 +63,7 @@
kBlockInsecurePrivateNetworkRequestsDeprecationTrial);
CONTENT_EXPORT BASE_DECLARE_FEATURE(
kBrokerFileOperationsOnDiskCacheInNetworkService);
+CONTENT_EXPORT BASE_DECLARE_FEATURE(kBypassRedirectChecksPerRequest);
CONTENT_EXPORT BASE_DECLARE_FEATURE(kCacheControlNoStoreEnterBackForwardCache);
CONTENT_EXPORT BASE_DECLARE_FEATURE(kCapturedSurfaceControlKillswitch);
CONTENT_EXPORT BASE_DECLARE_FEATURE(
diff --git a/extensions/browser/api/web_request/web_request_proxying_url_loader_factory.cc b/extensions/browser/api/web_request/web_request_proxying_url_loader_factory.cc
index e9a9f23..0e15df2 100644
--- a/extensions/browser/api/web_request/web_request_proxying_url_loader_factory.cc
+++ b/extensions/browser/api/web_request/web_request_proxying_url_loader_factory.cc
@@ -452,7 +452,19 @@
TRACE_ID_LOCAL(request_id_)),
TRACE_EVENT_FLAG_FLOW_IN | TRACE_EVENT_FLAG_FLOW_OUT);
- if (redirect_url_ != redirect_info.new_url &&
+ // An extension can intercept the headers of a response and issue a redirect
+ // to a different URL. In that case `redirect_url_` was set by the proxying
+ // extension and passed to the Network Service to synthesize a redirect.
+ // `redirect_info.new_url` is part of the synthesized redirect coming from the
+ // Network Service. If the two match, we know the extension initiated the
+ // redirect, so we can trust it.
+ bool redirect_url_comes_from_extension =
+ redirect_url_ == redirect_info.new_url;
+ if (redirect_url_comes_from_extension) {
+ head->bypass_redirect_checks = true;
+ }
+
+ if (!redirect_url_comes_from_extension &&
!IsRedirectSafe(request_.url, redirect_info.new_url,
info_->is_navigation_request)) {
OnNetworkError(CreateURLLoaderCompletionStatus(net::ERR_UNSAFE_REDIRECT));
@@ -708,6 +720,7 @@
head->headers = base::MakeRefCounted<net::HttpResponseHeaders>(
net::HttpUtil::AssembleRawHeaders(headers));
head->encoded_data_length = 0;
+ head->bypass_redirect_checks = true;
current_response_ = std::move(head);
ContinueToBeforeRedirect(redirect_info, net::OK);
@@ -1212,6 +1225,10 @@
request_, new_url, override_headers_->response_code(),
net::RedirectUtil::GetReferrerPolicyHeader(override_headers_.get()));
+ // Since this is an extension-generated redirect, we need to tell
+ // the client to bypass redirect checks.
+ current_response_->bypass_redirect_checks = true;
+
// These will get re-bound if a new request is initiated by
// |FollowRedirect()|.
proxied_client_receiver_.reset();
diff --git a/services/network/public/mojom/url_response_head.mojom b/services/network/public/mojom/url_response_head.mojom
index c177443..c77d065 100644
--- a/services/network/public/mojom/url_response_head.mojom
+++ b/services/network/public/mojom/url_response_head.mojom
@@ -325,4 +325,7 @@
// True if the synthetic response is used. This indicates the response headers
// are returned with the locally storead headers. See crbug.com/352578800.
bool from_synthetic_response = false;
+
+ // True when a proxy will handle the redirect checks.
+ bool bypass_redirect_checks = false;
};
diff --git a/chrome/browser/extensions/api/web_request/web_request_apitest.cc b/chrome/browser/extensions/api/web_request/web_request_apitest.cc
index 48495fa..17b7b8fb 100644
--- a/chrome/browser/extensions/api/web_request/web_request_apitest.cc
+++ b/chrome/browser/extensions/api/web_request/web_request_apitest.cc
@@ -31,6 +31,7 @@
#include "base/test/run_until.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/test_future.h"
+#include "base/test/with_feature_override.h"
#include "base/time/time.h"
#include "base/time/time_override.h"
#include "base/values.h"
@@ -7263,6 +7264,69 @@
EXPECT_TRUE(navigation_observer.last_navigation_succeeded());
}
+class ManifestV3WebRequestApiTestWithBypassRedirectChecksPerRequest
+ : public ManifestV3WebRequestApiTest,
+ public base::test::WithFeatureOverride {
+ public:
+ ManifestV3WebRequestApiTestWithBypassRedirectChecksPerRequest()
+ : WithFeatureOverride(features::kBypassRedirectChecksPerRequest) {}
+};
+
+// Tests service workers can't redirect to unsafe URLs when WebRequest
+// extensions are proxying requests. Regression test for crbug.com/379337758.
+IN_PROC_BROWSER_TEST_P(
+ ManifestV3WebRequestApiTestWithBypassRedirectChecksPerRequest,
+ ServiceWorkerCantRedirectToUnsafeUrls) {
+ ASSERT_TRUE(StartEmbeddedTestServer());
+
+ static constexpr char kManifest[] =
+ R"({
+ "name": "MV3 WebRequest",
+ "version": "0.1",
+ "manifest_version": 3,
+ "permissions": ["webRequest", "webRequestBlocking"],
+ "host_permissions": ["<all_urls>"],
+ "background": {"service_worker": "background.js"}
+ })";
+ // Listen to all requests to ensure they're proxied.
+ static constexpr char kBackgroundJs[] =
+ R"(chrome.webRequest.onBeforeRequest.addListener(
+ (details) => { return {}; },
+ {urls: ['<all_urls>'], types: ['main_frame']},
+ ['blocking']);
+ chrome.test.sendMessage('ready');)";
+
+ TestExtensionDir test_dir;
+ test_dir.WriteManifest(kManifest);
+ test_dir.WriteFile(FILE_PATH_LITERAL("background.js"), kBackgroundJs);
+ const Extension* extension = LoadPolicyExtension(test_dir);
+ ASSERT_TRUE(extension);
+
+ // Setup a service worker that redirects all requests to a "data:" URL.
+ content::WebContents* web_contents = GetActiveWebContents();
+ const GURL sw_url = embedded_test_server()->GetURL(
+ "/service_worker/service_worker_setup_data_redirect.html");
+ EXPECT_TRUE(NavigateToURL(sw_url));
+ EXPECT_EQ("ok", EvalJs(web_contents, "setup();"));
+
+ // Navigate to a URL that the service worker is monitoring.
+ content::TestNavigationObserver nav_observer(web_contents);
+ const GURL url = embedded_test_server()->GetURL("/service_worker/test");
+
+ const bool per_request_bypass = IsParamFeatureEnabled();
+ if (per_request_bypass) {
+ EXPECT_FALSE(NavigateToURL(url));
+ EXPECT_EQ(net::ERR_UNSAFE_REDIRECT, nav_observer.last_net_error_code());
+ } else {
+ EXPECT_TRUE(NavigateToURL(url));
+ EXPECT_EQ(net::OK, nav_observer.last_net_error_code());
+ }
+}
+
+// Toggle `features::BypassRedirectChecksPerRequest`.
+INSTANTIATE_FEATURE_OVERRIDE_TEST_SUITE(
+ ManifestV3WebRequestApiTestWithBypassRedirectChecksPerRequest);
+
class OnAuthRequiredApiTest : public ExtensionApiTest {
public:
static constexpr char kTestDomain[] = "a.test";
diff --git a/chrome/test/data/service_worker/service_worker_data_redirect.js b/chrome/test/data/service_worker/service_worker_data_redirect.js
new file mode 100644
index 0000000..0c0d9df
--- /dev/null
+++ b/chrome/test/data/service_worker/service_worker_data_redirect.js
@@ -0,0 +1,8 @@
+// Copyright 2025 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+self.addEventListener('fetch', event => {
+ event.respondWith(Response.redirect(
+ 'data:text/html,<script>console.log("data")</script>'));
+});
diff --git a/chrome/test/data/service_worker/service_worker_setup_data_redirect.html b/chrome/test/data/service_worker/service_worker_setup_data_redirect.html
new file mode 100644
index 0000000..61b93f6
--- /dev/null
+++ b/chrome/test/data/service_worker/service_worker_setup_data_redirect.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>Register a service worker that redirects to data: URLs</title>
+<script>
+async function setup() {
+ let script_file = 'service_worker_data_redirect.js';
+ await navigator.serviceWorker.register(script_file);
+ await navigator.serviceWorker.ready;
+ return 'ok';
+}
+</script>
Regression Test / PoC
diff --git a/chrome/browser/extensions/api/web_request/web_request_apitest.cc b/chrome/browser/extensions/api/web_request/web_request_apitest.cc
index 48495fa..17b7b8fb 100644
--- a/chrome/browser/extensions/api/web_request/web_request_apitest.cc
+++ b/chrome/browser/extensions/api/web_request/web_request_apitest.cc
@@ -31,6 +31,7 @@
#include "base/test/run_until.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/test_future.h"
+#include "base/test/with_feature_override.h"
#include "base/time/time.h"
#include "base/time/time_override.h"
#include "base/values.h"
@@ -7263,6 +7264,69 @@
EXPECT_TRUE(navigation_observer.last_navigation_succeeded());
}
+class ManifestV3WebRequestApiTestWithBypassRedirectChecksPerRequest
+ : public ManifestV3WebRequestApiTest,
+ public base::test::WithFeatureOverride {
+ public:
+ ManifestV3WebRequestApiTestWithBypassRedirectChecksPerRequest()
+ : WithFeatureOverride(features::kBypassRedirectChecksPerRequest) {}
+};
+
+// Tests service workers can't redirect to unsafe URLs when WebRequest
+// extensions are proxying requests. Regression test for crbug.com/379337758.
+IN_PROC_BROWSER_TEST_P(
+ ManifestV3WebRequestApiTestWithBypassRedirectChecksPerRequest,
+ ServiceWorkerCantRedirectToUnsafeUrls) {
+ ASSERT_TRUE(StartEmbeddedTestServer());
+
+ static constexpr char kManifest[] =
+ R"({
+ "name": "MV3 WebRequest",
+ "version": "0.1",
+ "manifest_version": 3,
+ "permissions": ["webRequest", "webRequestBlocking"],
+ "host_permissions": ["<all_urls>"],
+ "background": {"service_worker": "background.js"}
+ })";
+ // Listen to all requests to ensure they're proxied.
+ static constexpr char kBackgroundJs[] =
+ R"(chrome.webRequest.onBeforeRequest.addListener(
+ (details) => { return {}; },
+ {urls: ['<all_urls>'], types: ['main_frame']},
+ ['blocking']);
+ chrome.test.sendMessage('ready');)";
+
+ TestExtensionDir test_dir;
+ test_dir.WriteManifest(kManifest);
+ test_dir.WriteFile(FILE_PATH_LITERAL("background.js"), kBackgroundJs);
+ const Extension* extension = LoadPolicyExtension(test_dir);
+ ASSERT_TRUE(extension);
+
+ // Setup a service worker that redirects all requests to a "data:" URL.
+ content::WebContents* web_contents = GetActiveWebContents();
+ const GURL sw_url = embedded_test_server()->GetURL(
+ "/service_worker/service_worker_setup_data_redirect.html");
+ EXPECT_TRUE(NavigateToURL(sw_url));
+ EXPECT_EQ("ok", EvalJs(web_contents, "setup();"));
+
+ // Navigate to a URL that the service worker is monitoring.
+ content::TestNavigationObserver nav_observer(web_contents);
+ const GURL url = embedded_test_server()->GetURL("/service_worker/test");
+
+ const bool per_request_bypass = IsParamFeatureEnabled();
+ if (per_request_bypass) {
+ EXPECT_FALSE(NavigateToURL(url));
+ EXPECT_EQ(net::ERR_UNSAFE_REDIRECT, nav_observer.last_net_error_code());
+ } else {
+ EXPECT_TRUE(NavigateToURL(url));
+ EXPECT_EQ(net::OK, nav_observer.last_net_error_code());
+ }
+}
+
+// Toggle `features::BypassRedirectChecksPerRequest`.
+INSTANTIATE_FEATURE_OVERRIDE_TEST_SUITE(
+ ManifestV3WebRequestApiTestWithBypassRedirectChecksPerRequest);
+
class OnAuthRequiredApiTest : public ExtensionApiTest {
public:
static constexpr char kTestDomain[] = "a.test";
diff --git a/chrome/test/data/service_worker/service_worker_data_redirect.js b/chrome/test/data/service_worker/service_worker_data_redirect.js
new file mode 100644
index 0000000..0c0d9df
--- /dev/null
+++ b/chrome/test/data/service_worker/service_worker_data_redirect.js
@@ -0,0 +1,8 @@
+// Copyright 2025 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+self.addEventListener('fetch', event => {
+ event.respondWith(Response.redirect(
+ 'data:text/html,<script>console.log("data")</script>'));
+});
diff --git a/chrome/test/data/service_worker/service_worker_setup_data_redirect.html b/chrome/test/data/service_worker/service_worker_setup_data_redirect.html
new file mode 100644
index 0000000..61b93f6
--- /dev/null
+++ b/chrome/test/data/service_worker/service_worker_setup_data_redirect.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>Register a service worker that redirects to data: URLs</title>
+<script>
+async function setup() {
+ let script_file = 'service_worker_data_redirect.js';
+ await navigator.serviceWorker.register(script_file);
+ await navigator.serviceWorker.ready;
+ return 'ok';
+}
+</script>
Original Bug Report
Service workers allowing redirects to data: URLs.
Steps to reproduce the problem
This issue is split from https://issues.chromium.org/40064165 someone provided a hosted PoC https://crbug40064165.glitch.me/
index.html
<script>
navigator.serviceWorker.register('swredirect.js');
setTimeout(() => {
location.reload();
}, 1000);
</script>
swredirect.js
self.addEventListener('fetch', function(event) {
event.respondWith(Response.redirect('data:text/html,<script>prompt("Test")</script>'));
});
Now doing open() to the HTTP URL of the service worker you will get redirected to a top-level data: URL.
Problem Description
Top-level navigations of this type are also not normally allowed. https://blog.mozilla.org/security/2017/11/27/blocking-top-level-navigations-data-urls-firefox-59/ This navigation inherits from the opener as shown for the origin in the protocol confirmation dialog and also leaks the victims CSP.
const observer = new ReportingObserver(
(reports, observer) => {
reports.forEach((violation) => {
console.log(violation);
console.log(JSON.stringify(violation));
});
},
{
types: ["csp-violation"],
buffered: true,
},
);
observer.observe();
CSP leak includes nonce values but with a migration of needing to comply with the victims CSP to abuse it, however a data: URL allows for custom HTML which is a larger attack surface.
Summary
Service workers allowing redirects to data: URLs.
Additional Data
Category: Security
Chrome Channel: Not sure
Regression: N/A