Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in ServiceWorker
DescriptionInformation leak in ServiceWorker
ComponentServiceWorker
Bug ClassLogic Error
Tracker517634590
Fix commit6784eed9c650 (chromium/src) +117/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
if
content/common/service_worker/race_network_request_url_loader_client_unittest.cc
modified
ServiceWorkerRaceNetworkRequestURLLoaderClientTest
content/common/service_worker/race_network_request_url_loader_client_unittest.cc
modified

Files Changed

  • content/common/service_worker/race_network_request_url_loader_client.cc
  • content/common/service_worker/race_network_request_url_loader_client_unittest.cc
From 6784eed9c650a156e305f2b7028de08afccbcc72 Mon Sep 17 00:00:00 2001
From: Shunya Shishido <[email protected]>
Date: Wed, 22 Jul 2026 23:47:38 -0700
Subject: [PATCH] ServiceWorker: Censor non-HTTP(S) redirect URL in race forward

ServiceWorkerRaceNetworkRequestURLLoaderClient forwards the network
redirect_info to forwarding_client_, which is connected to the fetch
handler in the renderer. For main resource race requests the upstream
loader runs as a browser navigation, so CorsURLLoader does not censor
non-HTTP(S) redirect targets and the raw URL was forwarded.

Replace redirect_info.new_url with "data:," for non-HTTP(S) schemes
before forwarding to the fetch handler, matching the behaviour
CorsURLLoader applies for renderer-destined manual redirects. The
original redirect_info is still passed to owner_->HandleRedirect() in
the kWithoutServiceWorker case, which runs in the browser and needs the
real target.

TAG=agy
CONV=569bdb49-a155-4b4d-9e86-7de205c02f43

Bug: 517634590
Change-Id: I6bd1a238ab6e6c06e7c9c81ca897cce58e2f8124
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8130660
Commit-Queue: Shunya Shishido <[email protected]>
Reviewed-by: Yoshisato Yanagisawa <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1666861}
---

diff --git a/content/common/service_worker/race_network_request_url_loader_client.cc b/content/common/service_worker/race_network_request_url_loader_client.cc
index 0f79c1dc..ecd9965 100644
--- a/content/common/service_worker/race_network_request_url_loader_client.cc
+++ b/content/common/service_worker/race_network_request_url_loader_client.cc
@@ -22,6 +22,7 @@
 #include "mojo/public/cpp/system/handle_signals_state.h"
 #include "net/http/http_response_headers.h"
 #include "net/http/http_status_code.h"
+#include "net/url_request/redirect_info.h"
 #include "services/network/public/cpp/features.h"
 #include "services/network/public/cpp/header_util.h"
 #include "services/network/public/cpp/record_ontransfersizeupdate_utils.h"
@@ -233,12 +234,23 @@
   // |owner| as a RaceNetworkResponse's response, we stop the race and back the
   // response to the fetch handler only instead, so that we guarantee the fetch
   // handler completion.
+  // For robustness, ensure non-HTTP(S) redirect URLs are replaced with "data:,"
+  // before forwarding to the client in the renderer process, matching the
+  // behavior of manual redirects handled by CorsURLLoader. The original
+  // redirect info is still passed to `owner_` in the kWithoutServiceWorker
+  // case where the redirect is handled directly within the browser process.
+  net::RedirectInfo forwarding_redirect_info = redirect_info;
+  if (!redirect_info.new_url.SchemeIsHTTPOrHTTPS()) {
+    forwarding_redirect_info.new_url = GURL("data:,");
+  }
+
   switch (owner_->commit_responsibility()) {
     case FetchResponseFrom::kNoResponseYet:
     case FetchResponseFrom::kSubresourceLoaderIsHandlingRedirect:
       // This happens when the response is faster than the fetch handler.
       owner_->SetCommitResponsibility(FetchResponseFrom::kServiceWorker);
-      forwarding_client_->OnReceiveRedirect(redirect_info, std::move(head));
+      forwarding_client_->OnReceiveRedirect(forwarding_redirect_info,
+                                            std::move(head));
       MaybeCompleteRedirectResponse(/*run_completion_callback=*/false);
       return;
     case FetchResponseFrom::kServiceWorker:
@@ -247,7 +259,8 @@
       // handler is already executed but in rare case in-flight request may be
       // used. Let the fetch handler side client to handle the rest. The fetch
       // handler side close the connection if it's not needed anyway.
-      forwarding_client_->OnReceiveRedirect(redirect_info, std::move(head));
+      forwarding_client_->OnReceiveRedirect(forwarding_redirect_info,
+                                            std::move(head));
       MaybeCompleteRedirectResponse(/*run_completion_callback=*/true);
       return;
     case FetchResponseFrom::kWithoutServiceWorker:
diff --git a/content/common/service_worker/race_network_request_url_loader_client_unittest.cc b/content/common/service_worker/race_network_request_url_loader_client_unittest.cc
index 6ea37d5..b93b89f 100644
--- a/content/common/service_worker/race_network_request_url_loader_client_unittest.cc
+++ b/content/common/service_worker/race_network_request_url_loader_client_unittest.cc
@@ -16,6 +16,7 @@
 #include "mojo/public/cpp/system/data_pipe.h"
 #include "mojo/public/cpp/system/simple_watcher.h"
 #include "net/base/net_errors.h"
+#include "net/url_request/redirect_info.h"
 #include "services/network/public/cpp/loading_params.h"
 #include "services/network/public/cpp/resource_request.h"
 #include "services/network/public/mojom/url_loader.mojom.h"
@@ -77,7 +78,13 @@
   }
   void HandleRedirect(
       const net::RedirectInfo& redirect_info,
-      const network::mojom::URLResponseHeadPtr& response_head) override {}
+      const network::mojom::URLResponseHeadPtr& response_head) override {
+    received_redirect_info_ = redirect_info;
+  }
+
+  const std::optional<net::RedirectInfo>& received_redirect_info() const {
+    return received_redirect_info_;
+  }
 
   base::WeakPtr<MockServiceWorkerResourceLoader> GetWeakPtr() {
     return weak_factory_.GetWeakPtr();
@@ -94,6 +101,7 @@
  private:
   OnCommitResponseCallback on_commit_response_;
   OnCompletedCallback on_commit_completed_;
+  std::optional<net::RedirectInfo> received_redirect_info_;
   base::WeakPtrFactory<MockServiceWorkerResourceLoader> weak_factory_{this};
 };
 
@@ -204,15 +212,30 @@
     WatchResponseBody(head, std::move(body));
   }
   void OnReceiveRedirect(const net::RedirectInfo& redirect_info,
-                         network::mojom::URLResponseHeadPtr head) override {}
+                         network::mojom::URLResponseHeadPtr head) override {
+    received_redirect_info_ = redirect_info;
+    if (on_receive_redirect_callback_) {
+      std::move(on_receive_redirect_callback_).Run();
+    }
+  }
   void OnUploadProgress(int64_t current_position,
                         int64_t total_size,
                         base::OnceCallback<void()> callback) override {}
   void OnTransferSizeUpdated(int32_t transfer_size_diff) override {}
   void OnComplete(const network::URLLoaderCompletionStatus& status) override {}
 
+  void SetOnReceiveRedirectCallback(base::OnceClosure callback) {
+    on_receive_redirect_callback_ = std::move(callback);
+  }
+
+  const std::optional<net::RedirectInfo>& received_redirect_info() const {
+    return received_redirect_info_;
+  }
+
  private:
   mojo::Receiver<network::mojom::URLLoaderClient> receiver_{this};
+  std::optional<net::RedirectInfo> received_redirect_info_;
+  base::OnceClosure on_receive_redirect_callback_;
 };
 
 class ServiceWorkerRaceNetworkRequestURLLoaderClientTest
@@ -254,6 +277,10 @@
     return client_for_fetch_handler_.get();
   }
 
+  ServiceWorkerRaceNetworkRequestURLLoaderClient* client() {
+    return client_.get();
+  }
+
  protected:
   void SetUp() override {
     ASSERT_EQ(CreateDataPipe(producer_, consumer_), MOJO_RESULT_OK);
@@ -561,4 +588,77 @@
   EXPECT_EQ(client_state(),
             ServiceWorkerRaceNetworkRequestURLLoaderClient::State::kCompleted);
 }
+
+TEST_F(ServiceWorkerRaceNetworkRequestURLLoaderClientTest,
+       RedirectForwardedToFetchHandlerForNonHttpScheme) {
+  const uint32_t data_pipe_capacity_num_bytes = 8;
+  SetUpURLLoaderClient(data_pipe_capacity_num_bytes);
+
+  base::RunLoop run_loop;
+  client_for_fetch_handler()->SetOnReceiveRedirectCallback(
+      run_loop.QuitClosure());
+
+  net::RedirectInfo redirect_info;
+  redirect_info.new_url = GURL("filesystem:http://example.com/temporary/test");
+  redirect_info.status_code = 302;
+  redirect_info.new_method = "GET";
+
+  network::mojom::URLResponseHeadPtr head(
+      network::CreateURLResponseHead(net::HTTP_FOUND));
+
+  client()->OnReceiveRedirect(redirect_info, std::move(head));
+  run_loop.Run();
+
+  ASSERT_TRUE(client_for_fetch_handler()->received_redirect_info().has_value());
+  EXPECT_EQ(client_for_fetch_handler()->received_redirect_info()->new_url,
+            GURL("data:,"));
+}
+
+TEST_F(ServiceWorkerRaceNetworkRequestURLLoaderClientTest,
+       RedirectForwardedToFetchHandlerForHttpScheme) {
+  const uint32_t data_pipe_capacity_num_bytes = 8;
+  SetUpURLLoaderClient(data_pipe_capacity_num_bytes);
+
+  base::RunLoop run_loop;
+  client_for_fetch_handler()->SetOnReceiveRedirectCallback(
+      run_loop.QuitClosure());
+
+  net::RedirectInfo redirect_info;
+  redirect_info.new_url = GURL("https://example.com/redirected");
+  redirect_info.status_code = 302;
+  redirect_info.new_method = "GET";
+
+  network::mojom::URLResponseHeadPtr head(
+      network::CreateURLResponseHead(net::HTTP_FOUND));
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/common/service_worker/race_network_request_url_loader_client_unittest.cc b/content/common/service_worker/race_network_request_url_loader_client_unittest.cc
index 6ea37d5..b93b89f 100644
--- a/content/common/service_worker/race_network_request_url_loader_client_unittest.cc
+++ b/content/common/service_worker/race_network_request_url_loader_client_unittest.cc
@@ -16,6 +16,7 @@
 #include "mojo/public/cpp/system/data_pipe.h"
 #include "mojo/public/cpp/system/simple_watcher.h"
 #include "net/base/net_errors.h"
+#include "net/url_request/redirect_info.h"
 #include "services/network/public/cpp/loading_params.h"
 #include "services/network/public/cpp/resource_request.h"
 #include "services/network/public/mojom/url_loader.mojom.h"
@@ -77,7 +78,13 @@
   }
   void HandleRedirect(
       const net::RedirectInfo& redirect_info,
-      const network::mojom::URLResponseHeadPtr& response_head) override {}
+      const network::mojom::URLResponseHeadPtr& response_head) override {
+    received_redirect_info_ = redirect_info;
+  }
+
+  const std::optional<net::RedirectInfo>& received_redirect_info() const {
+    return received_redirect_info_;
+  }
 
   base::WeakPtr<MockServiceWorkerResourceLoader> GetWeakPtr() {
     return weak_factory_.GetWeakPtr();
@@ -94,6 +101,7 @@
  private:
   OnCommitResponseCallback on_commit_response_;
   OnCompletedCallback on_commit_completed_;
+  std::optional<net::RedirectInfo> received_redirect_info_;
   base::WeakPtrFactory<MockServiceWorkerResourceLoader> weak_factory_{this};
 };
 
@@ -204,15 +212,30 @@
     WatchResponseBody(head, std::move(body));
   }
   void OnReceiveRedirect(const net::RedirectInfo& redirect_info,
-                         network::mojom::URLResponseHeadPtr head) override {}
+                         network::mojom::URLResponseHeadPtr head) override {
+    received_redirect_info_ = redirect_info;
+    if (on_receive_redirect_callback_) {
+      std::move(on_receive_redirect_callback_).Run();
+    }
+  }
   void OnUploadProgress(int64_t current_position,
                         int64_t total_size,
                         base::OnceCallback<void()> callback) override {}
   void OnTransferSizeUpdated(int32_t transfer_size_diff) override {}
   void OnComplete(const network::URLLoaderCompletionStatus& status) override {}
 
+  void SetOnReceiveRedirectCallback(base::OnceClosure callback) {
+    on_receive_redirect_callback_ = std::move(callback);
+  }
+
+  const std::optional<net::RedirectInfo>& received_redirect_info() const {
+    return received_redirect_info_;
+  }
+
  private:
   mojo::Receiver<network::mojom::URLLoaderClient> receiver_{this};
+  std::optional<net::RedirectInfo> received_redirect_info_;
+  base::OnceClosure on_receive_redirect_callback_;
 };
 
 class ServiceWorkerRaceNetworkRequestURLLoaderClientTest
@@ -254,6 +277,10 @@
     return client_for_fetch_handler_.get();
   }
 
+  ServiceWorkerRaceNetworkRequestURLLoaderClient* client() {
+    return client_.get();
+  }
+
  protected:
   void SetUp() override {
     ASSERT_EQ(CreateDataPipe(producer_, consumer_), MOJO_RESULT_OK);
@@ -561,4 +588,77 @@
   EXPECT_EQ(client_state(),
             ServiceWorkerRaceNetworkRequestURLLoaderClient::State::kCompleted);
 }
+
+TEST_F(ServiceWorkerRaceNetworkRequestURLLoaderClientTest,
+       RedirectForwardedToFetchHandlerForNonHttpScheme) {
+  const uint32_t data_pipe_capacity_num_bytes = 8;
+  SetUpURLLoaderClient(data_pipe_capacity_num_bytes);
+
+  base::RunLoop run_loop;
+  client_for_fetch_handler()->SetOnReceiveRedirectCallback(
+      run_loop.QuitClosure());
+
+  net::RedirectInfo redirect_info;
+  redirect_info.new_url = GURL("filesystem:http://example.com/temporary/test");
+  redirect_info.status_code = 302;
+  redirect_info.new_method = "GET";
+
+  network::mojom::URLResponseHeadPtr head(
+      network::CreateURLResponseHead(net::HTTP_FOUND));
+
+  client()->OnReceiveRedirect(redirect_info, std::move(head));
+  run_loop.Run();
+
+  ASSERT_TRUE(client_for_fetch_handler()->received_redirect_info().has_value());
+  EXPECT_EQ(client_for_fetch_handler()->received_redirect_info()->new_url,
+            GURL("data:,"));
+}
+
+TEST_F(ServiceWorkerRaceNetworkRequestURLLoaderClientTest,
+       RedirectForwardedToFetchHandlerForHttpScheme) {
+  const uint32_t data_pipe_capacity_num_bytes = 8;
+  SetUpURLLoaderClient(data_pipe_capacity_num_bytes);
+
+  base::RunLoop run_loop;
+  client_for_fetch_handler()->SetOnReceiveRedirectCallback(
+      run_loop.QuitClosure());
+
+  net::RedirectInfo redirect_info;
+  redirect_info.new_url = GURL("https://example.com/redirected");
+  redirect_info.status_code = 302;
+  redirect_info.new_method = "GET";
+
+  network::mojom::URLResponseHeadPtr head(
+      network::CreateURLResponseHead(net::HTTP_FOUND));
+
+  client()->OnReceiveRedirect(redirect_info, std::move(head));
+  run_loop.Run();
+
+  ASSERT_TRUE(client_for_fetch_handler()->received_redirect_info().has_value());
+  EXPECT_EQ(client_for_fetch_handler()->received_redirect_info()->new_url,
+            GURL("https://example.com/redirected"));
+}
+
+TEST_F(ServiceWorkerRaceNetworkRequestURLLoaderClientTest,
+       RedirectHandledByOwnerForNonHttpScheme) {
+  const uint32_t data_pipe_capacity_num_bytes = 8;
+  SetUpURLLoaderClient(data_pipe_capacity_num_bytes);
+
+  owner()->SetCommitResponsibility(
+      ServiceWorkerResourceLoader::FetchResponseFrom::kWithoutServiceWorker);
+
+  net::RedirectInfo redirect_info;
+  redirect_info.new_url = GURL("filesystem:http://example.com/temporary/test");
+  redirect_info.status_code = 302;
+  redirect_info.new_method = "GET";
+
+  network::mojom::URLResponseHeadPtr head(
+      network::CreateURLResponseHead(net::HTTP_FOUND));
+
+  client()->OnReceiveRedirect(redirect_info, std::move(head));
+
+  ASSERT_TRUE(owner()->received_redirect_info().has_value());
+  EXPECT_EQ(owner()->received_redirect_info()->new_url,
+            GURL("filesystem:http://example.com/temporary/test"));
+}
 }  // namespace content
Loading diff…

Original Bug Report

reported by [email protected]

Bypass of manual redirect URL censoring in Service Worker race-network-request

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 security boundary violation in the Service Worker race-network-request path potentially allows a compromised renderer to observe uncensored non-HTTP(S) redirect targets in manual redirect mode. This bypass occurs because the underlying loader factory is created with browser-process privileges, whereas the client data pipe is fused directly to the renderer. Consequently, manual redirect censoring in the network process is skipped, potentially leaking internal redirect targets.

Affected files:

  • services/network/cors/cors_url_loader.cc
  • content/common/service_worker/forwarded_race_network_request_url_loader_factory.cc
  • content/browser/loader/navigation_url_loader_impl.cc
  • content/common/service_worker/race_network_request_url_loader_client.cc
  • content/browser/service_worker/service_worker_client.cc

Estimated timestamp from git blame: 2026-02-17

Root Cause Analysis

In CorsURLLoader::OnReceiveRedirect, when redirect_mode == kManual, any non-HTTP(S) redirect_info.new_url is designed to be censored to "data:," before being forwarded to the client, unless the loader believes the navigation is browser-initiated:

// services/network/cors/cors_url_loader.cc
if (request_.redirect_mode == mojom::RedirectMode::kManual) {
  ...
  net::RedirectInfo censored_redirect_info = redirect_info;
  const bool is_browser_navigation =
      request_.mode == mojom::RequestMode::kNavigate &&
      process_id_ == OriginatingProcessId::browser();
  if (!is_browser_navigation &&
      !redirect_info.new_url.SchemeIsHTTPOrHTTPS()) {
    censored_redirect_info.new_url = GURL("data:,");
  }
  ...
  forwarding_client_->OnReceiveRedirect(censored_redirect_info,
                                        std::move(response_head));

This censoring acts as a defense-in-depth mechanism to protect the renderer from observing sensitive non-HTTP(S) redirect targets (such as internal schemes, custom protocols, or extension URLs).

However, for a Service Worker race-network-request main-resource navigation, this censoring check is bypassed. The loader factory for the race request is created via ServiceWorkerClient::CreateNetworkURLLoaderFactory and NavigationURLLoaderImpl::CreateURLLoaderFactoryWithHeaderClient, which configures the factory with browser process credentials:

// content/browser/loader/navigation_url_loader_impl.cc
network::mojom::URLLoaderFactoryParamsPtr params =
    network::mojom::URLLoaderFactoryParams::New();
params->header_client = std::move(header_client);
params->process_id = network::OriginatingProcessId::browser();
params->is_trusted = true;

Because process_id is set to OriginatingProcessId::browser(), the CorsURLLoader evaluates is_browser_navigation to true and skips the redirect URL censoring, assuming the redirect will be handled safely within the browser process. In reality, the client data-pipe of the race-network-request is eventually fused directly to the renderer’s URLLoaderClient in ServiceWorkerForwardedRaceNetworkRequestURLLoaderFactory::CreateLoaderAndStart using mojo::FusePipes.

Furthermore, when the manual redirect is delivered to the renderer in manual redirect mode, the response’s type is FetchResponseType::kOpaqueRedirect. In MojoURLLoaderClient::OnReceiveRedirect, the renderer explicitly skips calling Platform::Current()->IsRedirectSafe for manual/opaque redirects, meaning the renderer-side scheme safety checks are completely bypassed.

Potential Steps to Trigger / Exploit

Note: Our tooling does not currently have the capability to execute code, so these are potential steps based on code analysis.

  1. A compromised renderer registers a Service Worker scope that includes a static routing rule configured with a router source of ServiceWorkerRouterSourceType::kRaceNetworkAndFetchEvent (the race-network-and-fetch-handler source).
  2. The renderer triggers a main-resource navigation to a URL within the Service Worker’s scope.
  3. The browser intercepts the navigation, matches the rule, and starts the race-network-request concurrently by allocating a forwarding pipe pair.
  4. The browser passes the forwarded factory remote to the Service Worker renderer. The Service Worker calls fetch() on the race request token, which triggers mojo::FusePipes on the client pipe, linking the browser’s forwarding client directly to the renderer’s URLLoaderClient.
  5. During the network load of the race request, a manual redirect to a sensitive non-HTTP(S) target URL (such as an internal or extension URL rewritten by browser-side extension APIs) occurs.
  6. Because the network factory was created with OriginatingProcessId::browser(), CorsURLLoader::OnReceiveRedirect skips manual redirect censoring.
  7. The uncensored redirect_info is dispatched via forwarding_client_->OnReceiveRedirect and sent directly through the fused client pipe to the renderer.
  8. The renderer process reads the raw, uncensored non-HTTP(S) redirect target URL, bypassing the manual redirect URL censoring defense-in-depth protection.

Proposed Fix

To remediate this issue, ensure that Service Worker race-network-requests (which are intended to be exposed to the renderer) do not inherit browser-process privileges in a way that bypasses manual redirect censoring.

One potential solution is to explicitly identify whether a Navigation loader factory is being generated for a Service Worker race-network-request and, if so, omit setting params->process_id to OriginatingProcessId::browser(), or explicitly apply manual redirect URL censoring within ServiceWorkerRaceNetworkRequestURLLoaderClient::OnReceiveRedirect before forwarding the redirect info to the forwarding_client_ interface.

Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379


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