Medium watch CVSS 8.8 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Extensions
DescriptionInappropriate implementation in Extensions
ComponentExtensions
Sub-componentExtensions
Bug ClassLogic 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.
Tracker379337758
Fix commit2b49489ea2cb (chromium/src) +119/-2
CWECWE-693
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedNDevTK
Reported2024-11-16 · bounty $4000 (per vendor advisory)
Fix landed2025-07-10 (public git)
Disclosed2025-09-02
Patch gap54 days — fix public in source before the advisory

Background

`IsSafeRedirectTarget`
the browser-side check that NavigationURLLoaderImpl applies to every navigation redirect; it rejects unsafe targets such as data: URLs with net::ERR_UNSAFE_REDIRECT.
`WebRequestProxyingURLLoaderFactory`
the extensions layer that sits in front of network requests when an extension uses chrome.webRequest with blocking listeners, so the extension can observe, block or redirect requests (including issuing its own redirects).
`bypass_redirect_checks_`
a member of NavigationURLLoaderImpl that, when set, skips IsSafeRedirectTarget for 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.

Key insight
The exemption from redirect-safety checks was granted per navigation loader (whenever an extension proxy was present) instead of per redirect, so any redirect source on that loader inherited the extension’s trust. The fix moves the decision onto each URLResponseHead, and only the proxy sets it, and only for redirects the extension itself issued.

Attack Path

  1. Precondition: proxying extension The victim has an extension installed that registers a blocking chrome.webRequest listener for main_frame requests (the test uses a no-op onBeforeRequest returning {}), so navigations go through WebRequestProxyingURLLoaderFactory.
  2. Register a redirecting service worker An attacker-controlled site registers a service worker whose fetch handler responds with Response.redirect() to a data: URL (service_worker_data_redirect.js).
  3. 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, IsSafeRedirectTarget was skipped.
  4. Unsafe navigation commits The navigation to the data: URL succeeds (net::OK in the feature-off test), even though such a redirect should be blocked with net::ERR_UNSAFE_REDIRECT. Using this for spoofing or phishing content at the top level is inferred.

Impact Assessment

A web attacker can make navigations follow redirects that Chrome normally refuses as unsafe (demonstrated with a top-level 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

FunctionChangeNotes
if
extensions/browser/api/web_request/web_request_proxying_url_loader_factory.cc
modified
ManifestV3WebRequestApiTestWithBypassRedirectChecksPerRequest
chrome/browser/extensions/api/web_request/web_request_apitest.cc
modified
if
chrome/browser/extensions/api/web_request/web_request_apitest.cc
modified
OnAuthRequiredApiTest
chrome/browser/extensions/api/web_request/web_request_apitest.cc
modified

Files Changed

  • chrome/browser/extensions/api/web_request/web_request_apitest.cc
  • chrome/test/data/service_worker/service_worker_data_redirect.js
  • chrome/test/data/service_worker/service_worker_setup_data_redirect.html
  • content/browser/loader/navigation_url_loader_impl.cc
  • content/public/common/content_features.cc
  • content/public/common/content_features.h
  • extensions/browser/api/web_request/web_request_proxying_url_loader_factory.cc
  • services/network/public/mojom/url_response_head.mojom

Audit Directions

  • Coarse-grained trust flags
    Look 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, fetch interceptors or prefetch.
  • Interceptors ahead of proxies
    Navigation 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 fallbacks
    While kBypassRedirectChecksPerRequest exists, the old per-loader path can be re-enabled through field trials or policy; also check non-navigation loaders (subresources, workers) for an equivalent bypass_redirect_checks_ pattern that was not converted.
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>
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by nd...@protonmail.com

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

View on issue tracker