CVE-2026-79025
Overview
Background
- `WorkerScriptLoader`
- the browser-process (UI thread) loader that fetches a dedicated/shared worker’s main script and relays the
URLResponseHeadto the worker’s client viaclient_->OnReceiveResponse. - Blob URL worker scripts
- a
blob:worker script is served by a renderer-hostedBlobURLLoader(per the patch comment), so theURLResponseHeadthe browser receives for it originates from a less-trusted process rather than the network service. - Service-worker response fields
was_fetched_via_service_workerandurl_list_via_service_workerdescribe interception by a service worker; the worker script fetcher uses them, together with redirects, to compute the worker’s final response URL.- Address-space metadata
client_address_space,response_address_spaceandremote_endpointrecord where a response came from on the network and feed Private Network Access–style decisions; none of them is meaningful for ablob:load.
Root Cause Analysis
When a worker’s main script was loaded from a local URL such as blob:, the browser forwarded the URLResponseHead it received in WorkerScriptLoader’s response handler unchanged, even though that head is produced by a renderer-hosted BlobURLLoader and so can contain any field values. The worker script fetcher then computed the final response URL from main_script_load_params->response_head, so a head claiming was_fetched_via_service_worker with a non-empty url_list_via_service_worker could make a blob: script appear to have a different (for example cross-origin https:) final response URL. The invariant that was broken is that a local-scheme load never involves a service worker, the network or the HTTP cache, so its response URL must equal the initial request URL and its network/SW metadata must be at default values.
The fix enforces this in two places: the fetcher’s URL computation now returns initial_request_url early when initial_request_url.SchemeIsLocal(), and WorkerScriptLoader resets the SW-related, address-space, remote_endpoint, was_fetched_via_cache and is_validated fields to defaults for blob: requests before forwarding the head. The new unit test sends a head with was_fetched_via_service_worker = true, a cross-origin url_list_via_service_worker, loopback address spaces and a localhost endpoint, and checks that all of them are scrubbed. A new fetcher test case also checks that a blob: URL resolves to itself even when a SW URL list and redirects are present.
blob: worker scripts that is effectively renderer-supplied, which let fields that are impossible for a blob load (service-worker URL lists, network address spaces) influence the worker’s final URL and security metadata. The fix overrides those fields with local defaults and pins the final response URL to the initial blob: URL.Attack Path
- Attacker controls a renderer
The patch comment says the blob response is produced by a renderer-hosted
BlobURLLoader, so forging theURLResponseHeadmost plausibly requires a compromised renderer (inferred). Ordinary web content cannot set these fields through web APIs. - Start a worker from a `blob:` URL
The page creates a dedicated or shared worker whose main script URL is
blob:, which sends the load throughWorkerScriptLoaderin the browser. - Return a forged response head
The blob loader replies with a head that sets
was_fetched_via_service_worker, aurl_list_via_service_workerpointing at another origin, and/or spoofedclient_address_space/response_address_space/remote_endpoint/cache flags. This mirrors the values injected inResponseFromBlobUrl. - Browser adopts the forged metadata
Before the patch, the fetcher’s final-URL computation used the SW URL list, so the worker’s response URL could differ from its
blob:URL. The spoofed address-space data was also passed on to the worker client. Any later decision based on those values, such as policy or origin handling or Private Network Access checks, could be skewed (inferred).
Impact Assessment
blob: worker’s computed final response URL and its network/SW metadata as seen by the browser. Exploitation most likely needs a compromised renderer (inferred from the patch comment that a renderer hosts the blob loader). Any resulting bypass of origin- or address-space–based checks is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
forcontent/browser/worker_host/worker_script_fetcher_unittest.cc |
modified | |
TEST_Fcontent/browser/worker_host/worker_script_loader_factory_unittest.cc |
modified |
Files Changed
content/browser/worker_host/worker_script_fetcher_unittest.cccontent/browser/worker_host/worker_script_loader.cccontent/browser/worker_host/worker_script_loader_factory_unittest.cccontent/browser/worker_host/worker_script_fetcher.cc
Audit Directions
- Renderer-originated `URLResponseHead`s in the browserFind other places where the browser receives a head from a renderer-hosted loader (
blob:,filesystem:,data:handling, or other renderer-sideURLLoaderimplementations) and check that SW, cache and network fields are scrubbed or ignored before use. - Final-URL / origin derivation from SW URL listsLook at every consumer of
url_list_via_service_workerandwas_fetched_via_service_worker(navigation, service worker main scripts, worklets) for cases where a scheme that cannot be SW-intercepted still lets those fields override the request URL. - Address-space trustAudit Private Network Access decisions that read
response_address_space,client_address_spaceorremote_endpointfrom response heads that did not come from the network service.
Patch
From 5663e65f27a7e6523ede473af83eb33efe4fd0d0 Mon Sep 17 00:00:00 2001
From: Yoshisto Yanagisawa <yyanagisawa@chromium.org>
Date: Mon, 27 Jul 2026 04:08:59 -0700
Subject: [PATCH] Sanitize response parameters for local worker script loads
Ensure response parameters received when loading local worker scripts (such as blob URLs) are properly sanitized and restricted to local defaults.
TAG=agy
CONV=d00718bc-39f5-483a-94a6-1bd6d20eab1f
Bug: 537846307
Change-Id: If1344306011bb4a2c3f5a4db0cc54fa2182acbf7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8142860
Reviewed-by: Hiroki Nakagawa <nhiroki@chromium.org>
Commit-Queue: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1668490}
---
diff --git a/content/browser/worker_host/worker_script_fetcher.cc b/content/browser/worker_host/worker_script_fetcher.cc
index db5a606..f49da48 100644
--- a/content/browser/worker_host/worker_script_fetcher.cc
+++ b/content/browser/worker_host/worker_script_fetcher.cc
@@ -690,6 +690,10 @@
blink::mojom::WorkerMainScriptLoadParams* main_script_load_params) {
DCHECK(main_script_load_params);
+ if (initial_request_url.SchemeIsLocal()) {
+ return initial_request_url;
+ }
+
network::mojom::URLResponseHead* url_response_head =
main_script_load_params->response_head.get();
diff --git a/content/browser/worker_host/worker_script_loader.cc b/content/browser/worker_host/worker_script_loader.cc
index dcccdae..5a9aa12 100644
--- a/content/browser/worker_host/worker_script_loader.cc
+++ b/content/browser/worker_host/worker_script_loader.cc
@@ -13,6 +13,7 @@
#include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h"
#include "content/public/common/url_utils.h"
+#include "net/base/ip_endpoint.h"
#include "net/base/load_timing_info.h"
#include "net/base/net_errors.h"
#include "net/url_request/redirect_util.h"
@@ -223,6 +224,25 @@
mojo::ScopedDataPipeConsumerHandle body,
std::optional<mojo_base::BigBuffer> cached_metadata) {
DCHECK_CURRENTLY_ON(BrowserThread::UI);
+ if (resource_request_.url.SchemeIsBlob() && response_head) {
+ // A blob URL is loaded by a renderer-hosted BlobURLLoader. A blob URL
+ // request is never handled by a service worker or a network socket.
+ // Sanitize service worker and network address space fields to ensure
+ // robustness against invalid input and maintain expected specifications.
+ response_head->was_fetched_via_service_worker = false;
+ response_head->url_list_via_service_worker.clear();
+ response_head->service_worker_response_source =
+ network::mojom::FetchResponseSource::kUnspecified;
+ response_head->initial_service_worker_status.reset();
+ response_head->service_worker_router_info.reset();
+ response_head->client_address_space =
+ network::mojom::IPAddressSpace::kUnknown;
+ response_head->response_address_space =
+ network::mojom::IPAddressSpace::kUnknown;
+ response_head->remote_endpoint = net::IPEndPoint();
+ response_head->was_fetched_via_cache = false;
+ response_head->is_validated = false;
+ }
client_->OnReceiveResponse(std::move(response_head), std::move(body),
std::move(cached_metadata));
}
diff --git a/content/browser/worker_host/worker_script_fetcher_unittest.cc b/content/browser/worker_host/worker_script_fetcher_unittest.cc
index 3b24e0c..b32dc0b 100644
--- a/content/browser/worker_host/worker_script_fetcher_unittest.cc
+++ b/content/browser/worker_host/worker_script_fetcher_unittest.cc
@@ -74,6 +74,12 @@
{GURL("https://redirect_1.com"), GURL("https://redirect_2.com")},
GURL("https://url_list_2.com"),
},
+ {
+ GURL("blob:https://initial.com/uuid"),
+ {GURL("https://url_list_1.com"), GURL("https://url_list_2.com")},
+ {GURL("https://redirect_1.com"), GURL("https://redirect_2.com")},
+ GURL("blob:https://initial.com/uuid"),
+ },
};
for (const auto& test_case : kTestCases) {
diff --git a/content/browser/worker_host/worker_script_loader_factory_unittest.cc b/content/browser/worker_host/worker_script_loader_factory_unittest.cc
index 4746b25f..0fa09e3 100644
--- a/content/browser/worker_host/worker_script_loader_factory_unittest.cc
+++ b/content/browser/worker_host/worker_script_loader_factory_unittest.cc
@@ -15,11 +15,14 @@
#include "content/browser/worker_host/worker_script_loader.h"
#include "content/public/test/browser_task_environment.h"
#include "content/test/fake_network_url_loader_factory.h"
+#include "net/base/ip_address.h"
+#include "net/base/ip_endpoint.h"
#include "net/base/isolation_info.h"
#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
#include "net/url_request/redirect_info.h"
#include "services/network/public/cpp/wrapper_shared_url_loader_factory.h"
#include "services/network/public/mojom/fetch_api.mojom.h"
+#include "services/network/public/mojom/ip_address_space.mojom.h"
#include "services/network/test/test_url_loader_client.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/tokens/tokens.h"
@@ -228,6 +231,66 @@
EXPECT_EQ(net::ERR_UNSAFE_REDIRECT, client.completion_status().error_code);
}
+// Tests that response headers received while loading a blob: URL are sanitized.
+// Loading a blob URL never produces service worker interception or network
+// address space metadata, so any unexpected fields must be reset before they
+// are forwarded to the client.
+TEST_F(WorkerScriptLoaderFactoryTest, ResponseFromBlobUrl) {
+ GURL url("blob:https://www.example.com/49146318-7a89-4041-9bcc-36e6b6eeef86");
+
+ // Defer the mock network load so we can inject a response on the in-flight
+ // load.
+ network_loader_factory_instance_->DeferHandleRequest();
+
+ // Create the factory.
+ auto factory = std::make_unique<WorkerScriptLoaderFactory>(
+ kProcessId, DedicatedOrSharedWorkerToken(),
+ net::IsolationInfo::CreateForInternalRequest(url::Origin::Create(url)),
+ service_worker_handle_.get(), browser_context_getter_,
+ network_loader_factory_);
+
+ // Start loading the script.
+ network::TestURLLoaderClient client;
+ mojo::PendingRemote<network::mojom::URLLoader> loader =
+ CreateTestLoaderAndStart(url, factory.get(), &client);
+ ASSERT_TRUE(base::test::RunUntil(
+ [&]() { return factory->GetScriptLoader() != nullptr; }));
+
+ // Simulate receiving a response from the blob load with unexpected fields.
+ auto response_head = network::mojom::URLResponseHead::New();
+ response_head->was_fetched_via_service_worker = true;
+ response_head->url_list_via_service_worker = {
+ GURL("https://other.example.com/worker.js")};
+ response_head->client_address_space =
+ network::mojom::IPAddressSpace::kLoopback;
+ response_head->response_address_space =
+ network::mojom::IPAddressSpace::kLoopback;
+ response_head->remote_endpoint =
+ net::IPEndPoint(net::IPAddress::IPv4Localhost(), 8080);
+ response_head->was_fetched_via_cache = true;
+ response_head->is_validated = true;
+
+ factory->GetScriptLoader()->OnReceiveResponse(
+ std::move(response_head), mojo::ScopedDataPipeConsumerHandle(),
+ std::nullopt);
+ client.RunUntilResponseReceived();
+
+ // Verify that the unexpected fields were sanitized.
+ ASSERT_TRUE(client.has_received_response());
+ EXPECT_FALSE(client.response_head()->was_fetched_via_service_worker);
+ EXPECT_TRUE(client.response_head()->url_list_via_service_worker.empty());
+ EXPECT_EQ(network::mojom::IPAddressSpace::kUnknown,
+ client.response_head()->client_address_space);
+ EXPECT_EQ(network::mojom::IPAddressSpace::kUnknown,
+ client.response_head()->response_address_space);
+ EXPECT_EQ(net::IPEndPoint(), client.response_head()->remote_endpoint);
+ factory->GetScriptLoader()->OnComplete(
+ network::URLLoaderCompletionStatus(net::OK));
+ factory->GetScriptLoader()->OnFetcherCallbackCalled();
+ client.RunUntilComplete();
+ EXPECT_EQ(net::OK, client.completion_status().error_code);
+}
+
// Tests that a redirect to an unsafe target scheme is rejected.
TEST_F(WorkerScriptLoaderFactoryTest, RejectUnsafeRedirectTarget) {
GURL url("https://www.example.com/worker.js");
Regression Test / PoC
diff --git a/content/browser/worker_host/worker_script_fetcher_unittest.cc b/content/browser/worker_host/worker_script_fetcher_unittest.cc
index 3b24e0c..b32dc0b 100644
--- a/content/browser/worker_host/worker_script_fetcher_unittest.cc
+++ b/content/browser/worker_host/worker_script_fetcher_unittest.cc
@@ -74,6 +74,12 @@
{GURL("https://redirect_1.com"), GURL("https://redirect_2.com")},
GURL("https://url_list_2.com"),
},
+ {
+ GURL("blob:https://initial.com/uuid"),
+ {GURL("https://url_list_1.com"), GURL("https://url_list_2.com")},
+ {GURL("https://redirect_1.com"), GURL("https://redirect_2.com")},
+ GURL("blob:https://initial.com/uuid"),
+ },
};
for (const auto& test_case : kTestCases) {
diff --git a/content/browser/worker_host/worker_script_loader_factory_unittest.cc b/content/browser/worker_host/worker_script_loader_factory_unittest.cc
index 4746b25f..0fa09e3 100644
--- a/content/browser/worker_host/worker_script_loader_factory_unittest.cc
+++ b/content/browser/worker_host/worker_script_loader_factory_unittest.cc
@@ -15,11 +15,14 @@
#include "content/browser/worker_host/worker_script_loader.h"
#include "content/public/test/browser_task_environment.h"
#include "content/test/fake_network_url_loader_factory.h"
+#include "net/base/ip_address.h"
+#include "net/base/ip_endpoint.h"
#include "net/base/isolation_info.h"
#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
#include "net/url_request/redirect_info.h"
#include "services/network/public/cpp/wrapper_shared_url_loader_factory.h"
#include "services/network/public/mojom/fetch_api.mojom.h"
+#include "services/network/public/mojom/ip_address_space.mojom.h"
#include "services/network/test/test_url_loader_client.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/tokens/tokens.h"
@@ -228,6 +231,66 @@
EXPECT_EQ(net::ERR_UNSAFE_REDIRECT, client.completion_status().error_code);
}
+// Tests that response headers received while loading a blob: URL are sanitized.
+// Loading a blob URL never produces service worker interception or network
+// address space metadata, so any unexpected fields must be reset before they
+// are forwarded to the client.
+TEST_F(WorkerScriptLoaderFactoryTest, ResponseFromBlobUrl) {
+ GURL url("blob:https://www.example.com/49146318-7a89-4041-9bcc-36e6b6eeef86");
+
+ // Defer the mock network load so we can inject a response on the in-flight
+ // load.
+ network_loader_factory_instance_->DeferHandleRequest();
+
+ // Create the factory.
+ auto factory = std::make_unique<WorkerScriptLoaderFactory>(
+ kProcessId, DedicatedOrSharedWorkerToken(),
+ net::IsolationInfo::CreateForInternalRequest(url::Origin::Create(url)),
+ service_worker_handle_.get(), browser_context_getter_,
+ network_loader_factory_);
+
+ // Start loading the script.
+ network::TestURLLoaderClient client;
+ mojo::PendingRemote<network::mojom::URLLoader> loader =
+ CreateTestLoaderAndStart(url, factory.get(), &client);
+ ASSERT_TRUE(base::test::RunUntil(
+ [&]() { return factory->GetScriptLoader() != nullptr; }));
+
+ // Simulate receiving a response from the blob load with unexpected fields.
+ auto response_head = network::mojom::URLResponseHead::New();
+ response_head->was_fetched_via_service_worker = true;
+ response_head->url_list_via_service_worker = {
+ GURL("https://other.example.com/worker.js")};
+ response_head->client_address_space =
+ network::mojom::IPAddressSpace::kLoopback;
+ response_head->response_address_space =
+ network::mojom::IPAddressSpace::kLoopback;
+ response_head->remote_endpoint =
+ net::IPEndPoint(net::IPAddress::IPv4Localhost(), 8080);
+ response_head->was_fetched_via_cache = true;
+ response_head->is_validated = true;
+
+ factory->GetScriptLoader()->OnReceiveResponse(
+ std::move(response_head), mojo::ScopedDataPipeConsumerHandle(),
+ std::nullopt);
+ client.RunUntilResponseReceived();
+
+ // Verify that the unexpected fields were sanitized.
+ ASSERT_TRUE(client.has_received_response());
+ EXPECT_FALSE(client.response_head()->was_fetched_via_service_worker);
+ EXPECT_TRUE(client.response_head()->url_list_via_service_worker.empty());
+ EXPECT_EQ(network::mojom::IPAddressSpace::kUnknown,
+ client.response_head()->client_address_space);
+ EXPECT_EQ(network::mojom::IPAddressSpace::kUnknown,
+ client.response_head()->response_address_space);
+ EXPECT_EQ(net::IPEndPoint(), client.response_head()->remote_endpoint);
+ factory->GetScriptLoader()->OnComplete(
+ network::URLLoaderCompletionStatus(net::OK));
+ factory->GetScriptLoader()->OnFetcherCallbackCalled();
+ client.RunUntilComplete();
+ EXPECT_EQ(net::OK, client.completion_status().error_code);
+}
+
// Tests that a redirect to an unsafe target scheme is rejected.
TEST_F(WorkerScriptLoaderFactoryTest, RejectUnsafeRedirectTarget) {
GURL url("https://www.example.com/worker.js");
Original Bug Report
LNA Bypass via Forged URLResponseHead in WorkerScriptLoader with Blob URL Main Script
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 compromised renderer can potentially register a self-hosted Blob URL as a worker main script to forge a response head during script loading. Because WorkerScriptLoader::OnReceiveResponse lacks validation gating for blob schemes, the forged response propagates downstream. This allows the worker to be assigned a kLoopback address space, completely bypassing Local Network Access (LNA) protections.
Affected files:
content/browser/worker_host/worker_script_loader.cccontent/browser/worker_host/dedicated_worker_host.cccontent/browser/worker_host/shared_worker_host.cccontent/browser/worker_host/worker_script_fetcher.cc
Estimated timestamp from git blame: 2022-02-02
Description
There is a potential vulnerability in Chromium’s worker script loading infrastructure where a compromised renderer can bypass Local Network Access (LNA) protections. This occurs because WorkerScriptLoader::OnReceiveResponse does not validate or sanitize responses received from blob URL scheme loaders.
Root Cause Analysis
- Blob URL Registration:
BlobURLStoreImpl::Registeraccepts an arbitrary renderer-suppliedblink.mojom::Blobremote and registers it in the browser-process registry (storage/browser/blob/blob_url_store_impl.cc:118). - Handing Client to Renderer: When a dedicated or shared worker main script is fetched from a Blob URL, the browser’s
WorkerScriptLoaderserves as theURLLoaderClient.BlobURLLoaderFactory::CreateLoaderAndStartforwards this client remote directly to the renderer-hostedBlobremote (storage/browser/blob/blob_url_loader_factory.cc:80-81). - Ungated OnReceiveResponse: The compromised renderer can invoke
URLLoaderClient::OnReceiveResponseon the browser-process client with a fully forgedURLResponseHead. UnlikeWorkerScriptLoader::OnReceiveRedirectwhich contains aSchemeIsBlob()gate to block unsafe transitions (content/browser/worker_host/worker_script_loader.cc:235),WorkerScriptLoader::OnReceiveResponsedoes not restrict or sanitize response parameters for blob URLs. - Downstream Spoofing: Because the renderer can forge fields such as
was_fetched_via_service_worker = true,client_address_space = kLoopback, andurl_list_via_service_worker = ["https://evil.com/forged"], the browser-sideDedicatedWorkerHostorSharedWorkerHostcalculates the worker’s security state based on these spoofed attributes. - LNA Bypass: Consequently, the browser mints a subresource
URLLoaderFactoryfor the worker whereip_address_spaceis set tokLoopback. This allows the renderer to query private network endpoints (e.g.,127.0.0.1or RFC1918 addresses) through the resulting factory without any LNA restrictions or permission prompts.
Note: These are suggested/potential steps of an exploit chain, as our tooling agent does not have the capability to run code or verify exploitability live.
Potential Steps to Trigger the Vulnerability
- From a compromised renderer (e.g.,
https://evil.com), register a self-hostedblink.mojom.Blobreceiver mapping to a local Blob URL (e.g.,blob:https://evil.com/UUID). - Call
ResolveAsBlobURLTokento retrieve aBlobURLTokencorresponding to that URL. - Trigger dedicated worker creation by calling
DedicatedWorkerHostFactory.CreateWorkerHostAndStartScriptLoadwith the Blob URL and the token. - The custom renderer-hosted Blob implementation receives the
Load(..., client)call. On the provided browser-sideURLLoaderClientremote, callOnReceiveResponsewith a forgedURLResponseHeadwhere:url_list_via_service_worker = ["https://evil.com/forged"]was_fetched_via_service_worker = trueclient_address_space = kLoopbackparsed_headersis non-null withcross_origin_embedder_policy = kNone
- Retrieve the subresource factory delivered via the resulting worker client and use it to successfully issue requests to loopback/private network addresses (e.g.,
http://127.0.0.1:8000/) without triggering LNA restrictions.
Suggested Fix
To remediate this issue, restrict or sanitize the incoming URLResponseHead when the resource request scheme is a Blob URL. In WorkerScriptLoader::OnReceiveResponse (content/browser/worker_host/worker_script_loader.cc:221), verify if resource_request_.url.SchemeIsBlob() is true. If it is, either sanitize the response_head by clearing out renderer-influenceable fields (such as url_list_via_service_worker, was_fetched_via_service_worker, and client_address_space) or reject the response entirely if unexpected fields are populated.
Evaluated with Chrome root at commit: 5a99d0c5d2ec6c066f5131e7868ff637440cc3dc
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.