CVE-2026-87557
Overview
Files Changed
content/browser/file_system/file_system_url_loader_factory.hcontent/browser/worker_host/dedicated_worker_host.cccontent/browser/worker_host/shared_worker_service_impl.cccontent/browser/worker_host/worker_browsertest.cc
Patch
From 0db5072577f34c19aedb711734737d8e6540899b Mon Sep 17 00:00:00 2001 From: Zainab Rizvi <[email protected]> Date: Thu, 06 Aug 2026 04:16:13 -0700 Subject: [PATCH] PlzWorker: Check that worker final response URL is committable Verify that the worker's renderer process is permitted to commit the main script's final response URL before adopting it in DedicatedWorkerHost and SharedWorkerServiceImpl. If the URL cannot be committed, reject the worker script load. Also grant commit access when the URL is same-origin with the worker's storage key origin (e.g. for isolated-app://). Add browser tests verifying that non-committable response URLs are rejected for dedicated and shared workers. Bug: 497635917 TAG=agy CONV=18d74d7e-1f1b-4b21-a38d-52218d65f900 Change-Id: I104e6d94f998c94de2c3ba5de568416ebd62098b Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8207261 Commit-Queue: Zainab Rizvi <[email protected]> Reviewed-by: Hiroki Nakagawa <[email protected]> Cr-Commit-Position: refs/heads/main@{#1674858} --- diff --git a/content/browser/file_system/file_system_url_loader_factory.h b/content/browser/file_system/file_system_url_loader_factory.h index a820d61..b28d4a7 100644 --- a/content/browser/file_system/file_system_url_loader_factory.h +++ b/content/browser/file_system/file_system_url_loader_factory.h @@ -36,9 +36,8 @@ // - For a factory created to pass to the renderer for subresource requests from // the frame: that renderer process's ID. // - For a factory created for a browser-initiated worker main script request: -// the ID of the process the worker will run in. -// TODO(crbug.com/41471904): We should specify kInvalidUniqueID for this -// worker main script case like the browser-initiated navigation case. +// ChildProcessHost::kInvalidUniqueID (permissions are checked later via +// CanCommitURL in PlzWorker). // - For a factory created to pass to the renderer for subresource requests from // the worker: that renderer process's ID. // diff --git a/content/browser/worker_host/dedicated_worker_host.cc b/content/browser/worker_host/dedicated_worker_host.cc index 8e448c3..908964a 100644 --- a/content/browser/worker_host/dedicated_worker_host.cc +++ b/content/browser/worker_host/dedicated_worker_host.cc @@ -29,6 +29,7 @@ #include "content/browser/renderer_host/frame_tree_node.h" #include "content/browser/renderer_host/local_network_access_util.h" #include "content/browser/renderer_host/render_frame_host_impl.h" +#include "content/browser/security/cpsp/child_process_security_policy_impl.h" #include "content/browser/service_worker/service_worker_client.h" #include "content/browser/service_worker/service_worker_context_core.h" #include "content/browser/service_worker/service_worker_main_resource_handle.h" @@ -454,8 +455,25 @@ return; } - // TODO(crbug.com/41471904): Check if the main script's final response - // URL is committable. + // The final response URL is derived from data that may have been supplied by + // a renderer (e.g., via the URL list of a service worker provided response), + // so make sure the worker process is allowed to commit it before adopting it + // as this worker's URL. + // + // Only grant commit permissions if the URL is same-origin with the worker's + // expected origin (e.g. for Isolated Web Apps or extensions). + if (url::Origin::Create(result->final_response_url) + .IsSameOriginWith(worker_storage_key_.origin())) { + ChildProcessSecurityPolicyImpl::GetInstance()->GrantCommitURL( + worker_process_host_->GetDeprecatedID(), result->final_response_url); + } + if (!ChildProcessSecurityPolicyImpl::GetInstance()->CanCommitURL( + worker_process_host_->GetDeprecatedID(), + result->final_response_url)) { + ScriptLoadStartFailed(network::URLLoaderCompletionStatus(net::ERR_ABORTED)); + return; + } + final_response_url_ = result->final_response_url; service_->NotifyWorkerFinalResponseURLDetermined(token_, result->final_response_url); diff --git a/content/browser/worker_host/shared_worker_service_impl.cc b/content/browser/worker_host/shared_worker_service_impl.cc index 9973fa8..7b3ffff 100644 --- a/content/browser/worker_host/shared_worker_service_impl.cc +++ b/content/browser/worker_host/shared_worker_service_impl.cc @@ -21,6 +21,7 @@ #include "base/timer/elapsed_timer.h" #include "content/browser/devtools/shared_worker_devtools_agent_host.h" #include "content/browser/loader/file_url_loader_factory.h" +#include "content/browser/security/cpsp/child_process_security_policy_impl.h" #include "content/browser/service_worker/service_worker_client.h" #include "content/browser/service_worker/service_worker_main_resource_handle.h" #include "content/browser/storage_partition_impl.h" @@ -591,8 +592,24 @@ return; } - // TODO(crbug.com/41471904): Check if the main script's final response - // URL is committable. + // The final response URL is derived from data that may have been supplied by + // a renderer (e.g., via the URL list of a service worker provided response), + // so make sure the worker process is allowed to commit it before adopting it + // as this worker's URL. + // + // Only grant commit permissions if the URL is same-origin with the worker's + // expected origin (e.g. for Isolated Web Apps or extensions). + if (url::Origin::Create(result->final_response_url) + .IsSameOriginWith(host->instance().worker_storage_key().origin())) { + ChildProcessSecurityPolicyImpl::GetInstance()->GrantCommitURL( + host->GetProcessHost()->GetDeprecatedID(), result->final_response_url); + } + if (!ChildProcessSecurityPolicyImpl::GetInstance()->CanCommitURL( + host->GetProcessHost()->GetDeprecatedID(), + result->final_response_url)) { + DestroyHost(host.get()); + return; + } // Get the factory used to instantiate the new shared worker instance in // the target process. diff --git a/content/browser/worker_host/worker_browsertest.cc b/content/browser/worker_host/worker_browsertest.cc index 9165709..69ab4b7 100644 --- a/content/browser/worker_host/worker_browsertest.cc +++ b/content/browser/worker_host/worker_browsertest.cc @@ -2,11 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +#include "base/byte_size.h" #include "base/check.h" #include "base/feature_list.h" #include "base/functional/bind.h" #include "base/functional/callback.h" #include "base/path_service.h" +#include "base/scoped_observation.h" #include "base/strings/escape.h" #include "base/strings/strcat.h" #include "base/strings/string_util.h" @@ -20,12 +22,15 @@ #include "base/threading/thread_restrictions.h" #include "build/build_config.h" #include "content/browser/process_lock.h" +#include "content/browser/security/cpsp/child_process_security_policy_impl.h" #include "content/browser/web_contents/web_contents_impl.h" #include "content/browser/worker_host/shared_worker_service_impl.h" #include "content/public/browser/browser_context.h" #include "content/public/browser/browser_task_traits.h" #include "content/public/browser/browser_thread.h" #include "content/public/browser/client_certificate_delegate.h" +#include "content/public/browser/dedicated_worker_service.h" +#include "content/public/browser/shared_worker_service.h" #include "content/public/browser/storage_partition.h" #include "content/public/browser/web_contents.h" #include "content/public/common/content_features.h" @@ -46,6 +51,7 @@ #include "net/cookies/canonical_cookie.h" #include "net/cookies/cookie_access_result.h" #include "net/dns/mock_host_resolver.h" +#include "net/http/http_response_headers.h" #include "net/ssl/client_cert_identity.h" #include "net/ssl/ssl_server_config.h" #include "net/test/embedded_test_server/connection_tracker.h" @@ -59,6 +65,8 @@ #include "services/network/public/mojom/connection_change_observer_client.mojom.h" #include "services/network/public/mojom/cookie_manager.mojom.h" #include "services/network/public/mojom/network_context.mojom.h" +#include "services/network/public/mojom/parsed_headers.mojom.h" +#include "services/network/public/mojom/url_response_head.mojom.h" #include "third_party/blink/public/common/features.h" #include "third_party/blink/public/common/storage_key/storage_key.h" #include "url/gurl.h" @@ -78,6 +86,90 @@ return base::FeatureList::IsEnabled(blink::features::kSharedWorker); } +// Writes a worker script response with the given `url_list` populated in the +// service worker URL list of the response head. +void WriteWorkerScriptResponseWithServiceWorkerUrlList( + network::mojom::URLLoaderClient* client, + const std::vector<GURL>& url_list) { + static constexpr char kBody[] = "postMessage('done');"; + auto response = network::mojom::URLResponseHead::New(); + response->headers = base::MakeRefCounted<net::HttpResponseHeaders>( + "HTTP/1.1 200 OK\nContent-Type: text/javascript\n\n"); + response->mime_type = "text/javascript"; + response->was_fetched_via_service_worker = true; + response->url_list_via_service_worker = url_list; + response->parsed_headers = network::mojom::ParsedHeaders::New(); + + mojo::ScopedDataPipeProducerHandle producer_handle; + mojo::ScopedDataPipeConsumerHandle consumer_handle; + EXPECT_EQ(mojo::CreateDataPipe(nullptr, producer_handle, consumer_handle), + MOJO_RESULT_OK); + if (producer_handle.is_valid()) { + EXPECT_EQ( + producer_handle->WriteAllData(base::byte_span_from_cstring(kBody)), + MOJO_RESULT_OK);
Regression Test / PoC
diff --git a/content/browser/worker_host/worker_browsertest.cc b/content/browser/worker_host/worker_browsertest.cc
index 9165709..69ab4b7 100644
--- a/content/browser/worker_host/worker_browsertest.cc
+++ b/content/browser/worker_host/worker_browsertest.cc
@@ -2,11 +2,13 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+#include "base/byte_size.h"
#include "base/check.h"
#include "base/feature_list.h"
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/path_service.h"
+#include "base/scoped_observation.h"
#include "base/strings/escape.h"
#include "base/strings/strcat.h"
#include "base/strings/string_util.h"
@@ -20,12 +22,15 @@
#include "base/threading/thread_restrictions.h"
#include "build/build_config.h"
#include "content/browser/process_lock.h"
+#include "content/browser/security/cpsp/child_process_security_policy_impl.h"
#include "content/browser/web_contents/web_contents_impl.h"
#include "content/browser/worker_host/shared_worker_service_impl.h"
#include "content/public/browser/browser_context.h"
#include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h"
#include "content/public/browser/client_certificate_delegate.h"
+#include "content/public/browser/dedicated_worker_service.h"
+#include "content/public/browser/shared_worker_service.h"
#include "content/public/browser/storage_partition.h"
#include "content/public/browser/web_contents.h"
#include "content/public/common/content_features.h"
@@ -46,6 +51,7 @@
#include "net/cookies/canonical_cookie.h"
#include "net/cookies/cookie_access_result.h"
#include "net/dns/mock_host_resolver.h"
+#include "net/http/http_response_headers.h"
#include "net/ssl/client_cert_identity.h"
#include "net/ssl/ssl_server_config.h"
#include "net/test/embedded_test_server/connection_tracker.h"
@@ -59,6 +65,8 @@
#include "services/network/public/mojom/connection_change_observer_client.mojom.h"
#include "services/network/public/mojom/cookie_manager.mojom.h"
#include "services/network/public/mojom/network_context.mojom.h"
+#include "services/network/public/mojom/parsed_headers.mojom.h"
+#include "services/network/public/mojom/url_response_head.mojom.h"
#include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/common/storage_key/storage_key.h"
#include "url/gurl.h"
@@ -78,6 +86,90 @@
return base::FeatureList::IsEnabled(blink::features::kSharedWorker);
}
+// Writes a worker script response with the given `url_list` populated in the
+// service worker URL list of the response head.
+void WriteWorkerScriptResponseWithServiceWorkerUrlList(
+ network::mojom::URLLoaderClient* client,
+ const std::vector<GURL>& url_list) {
+ static constexpr char kBody[] = "postMessage('done');";
+ auto response = network::mojom::URLResponseHead::New();
+ response->headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ "HTTP/1.1 200 OK\nContent-Type: text/javascript\n\n");
+ response->mime_type = "text/javascript";
+ response->was_fetched_via_service_worker = true;
+ response->url_list_via_service_worker = url_list;
+ response->parsed_headers = network::mojom::ParsedHeaders::New();
+
+ mojo::ScopedDataPipeProducerHandle producer_handle;
+ mojo::ScopedDataPipeConsumerHandle consumer_handle;
+ EXPECT_EQ(mojo::CreateDataPipe(nullptr, producer_handle, consumer_handle),
+ MOJO_RESULT_OK);
+ if (producer_handle.is_valid()) {
+ EXPECT_EQ(
+ producer_handle->WriteAllData(base::byte_span_from_cstring(kBody)),
+ MOJO_RESULT_OK);
+ }
+ producer_handle.reset();
+
+ client->OnReceiveResponse(std::move(response), std::move(consumer_handle),
+ std::nullopt);
+ network::URLLoaderCompletionStatus status;
+ status.error_code = net::OK;
+ status.decoded_body_length = base::ByteSize(sizeof(kBody) - 1);
+ client->OnComplete(status);
+}
+
+// Records final response URLs reported for newly created workers.
+class WorkerFinalResponseURLObserver : public DedicatedWorkerService::Observer,
+ public SharedWorkerService::Observer {
+ public:
+ explicit WorkerFinalResponseURLObserver(StoragePartition* storage_partition) {
+ dedicated_worker_observation_.Observe(
+ storage_partition->GetDedicatedWorkerService());
+ shared_worker_observation_.Observe(
+ storage_partition->GetSharedWorkerService());
+ }
+
+ const std::vector<GURL>& final_response_urls() const {
+ return final_response_urls_;
+ }
+
+ // DedicatedWorkerService::Observer:
+ void OnWorkerCreated(const blink::DedicatedWorkerToken&,
+ ChildProcessId,
+ const url::Origin&,
+ DedicatedWorkerCreator) override {}
+ void OnBeforeWorkerDestroyed(const blink::DedicatedWorkerToken&,
+ DedicatedWorkerCreator) override {}
+ void OnFinalResponseURLDetermined(const blink::DedicatedWorkerToken&,
+ const GURL& url) override {
+ final_response_urls_.push_back(url);
+ }
+
+ // SharedWorkerService::Observer:
+ void OnWorkerCreated(const blink::SharedWorkerToken&,
+ ChildProcessId,
+ const url::Origin&,
+ const base::UnguessableToken&) override {}
+ void OnBeforeWorkerDestroyed(const blink::SharedWorkerToken&) override {}
+ void OnFinalResponseURLDetermined(const blink::SharedWorkerToken&,
+ const GURL& url) override {
+ final_response_urls_.push_back(url);
+ }
+ void OnClientAdded(const blink::SharedWorkerToken&,
+ GlobalRenderFrameHostId) override {}
+ void OnClientRemoved(const blink::SharedWorkerToken&,
+ GlobalRenderFrameHostId) override {}
+
+ private:
+ std::vector<GURL> final_response_urls_;
+ base::ScopedObservation<DedicatedWorkerService,
+ DedicatedWorkerService::Observer>
+ dedicated_worker_observation_{this};
+ base::ScopedObservation<SharedWorkerService, SharedWorkerService::Observer>
+ shared_worker_observation_{this};
+};
+
} // namespace
class WorkerTest : public ContentBrowserTest {
@@ -314,6 +406,100 @@
RunTest(url, /*expect_failure=*/true);
}
+// Tests that a dedicated worker main script response whose service worker
+// supplied URL list resolves to a URL that the worker process cannot commit
+// is rejected.
+IN_PROC_BROWSER_TEST_F(WorkerTest,
+ DedicatedWorkerRejectsNonCommittableFinalResponseUrl) {
+ const GURL main_url = ssl_server()->GetURL("a.test", "/title1.html");
+ const GURL worker_url = ssl_server()->GetURL("a.test", "/workers/worker.js");
+ const GURL non_committable_url("file:///non_committable_path");
+
+ WorkerFinalResponseURLObserver observer(shell()
+ ->web_contents()
+ ->GetBrowserContext()
+ ->GetDefaultStoragePartition());
+
+ URLLoaderInterceptor interceptor(base::BindLambdaForTesting(
+ [&](URLLoaderInterceptor::RequestParams* params) {
+ if (params->url_request.url != worker_url) {
+ return false;
+ }
+ WriteWorkerScriptResponseWithServiceWorkerUrlList(
+ params->client.get(), {non_committable_url});
+ return true;
+ }));
+
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ EXPECT_FALSE(ChildProcessSecurityPolicyImpl::GetInstance()->CanCommitURL(
+ shell()
+ ->web_contents()
+ ->GetPrimaryMainFrame()
+ ->GetProcess()
+ ->GetDeprecatedID(),
+ non_committable_url));
+
+ EXPECT_EQ("error", EvalJs(shell(), R"(
+ new Promise(resolve => {
+ const worker = new Worker('/workers/worker.js');
+ worker.onerror = () => resolve('error');
+ worker.onmessage = e => resolve(e.data);
+ })
+ )"));
+
+ // The worker process is not allowed to commit `non_committable_url`, so it
+ // must not have been adopted as the worker's final response URL.
+ EXPECT_TRUE(observer.final_response_urls().empty())
+ << "unexpected final response URL: "
+ << observer.final_response_urls().front();
+}
+
+// Same as DedicatedWorkerRejectsNonCommittableFinalResponseUrl, but for shared
+// workers.
+IN_PROC_BROWSER_TEST_F(WorkerTest,
+ SharedWorkerRejectsNonCommittableFinalResponseUrl) {
+ if (!SupportsSharedWorker()) {
+ return;
+ }
+
+ const GURL main_url = ssl_server()->GetURL("a.test", "/title1.html");
+ const GURL worker_url = ssl_server()->GetURL("a.test", "/workers/worker.js");
+ const GURL non_committable_url("file:///non_committable_path");
+
+ WorkerFinalResponseURLObserver observer(shell()
+ ->web_contents()
+ ->GetBrowserContext()
+ ->GetDefaultStoragePartition());
+
+ URLLoaderInterceptor interceptor(base::BindLambdaForTesting(
+ [&](URLLoaderInterceptor::RequestParams* params) {
+ if (params->url_request.url != worker_url) {
+ return false;
+ }
+ WriteWorkerScriptResponseWithServiceWorkerUrlList(
+ params->client.get(), {non_committable_url});
+ return true;
+ }));
+
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ EXPECT_EQ("error", EvalJs(shell(), R"(
+ new Promise(resolve => {
+ const worker = new SharedWorker('/workers/worker.js');
+ worker.onerror = () => resolve('error');
+ worker.port.onmessage = e => resolve(e.data);
+ })
+ )"));
+
+ // The worker process is not allowed to commit `non_committable_url`, so it
+ // must not have been adopted as the worker's final response URL.
+ EXPECT_TRUE(observer.final_response_urls().empty())
+ << "unexpected final response URL: "
+ << observer.final_response_urls().front();
+ EXPECT_FALSE(GetSharedWorkerHost(worker_url));
+}
+
IN_PROC_BROWSER_TEST_F(WorkerTest, MultipleWorkers) {
RunTest(GetTestURL("multi_worker.html", std::string()));
}
Original Bug Report
Local Network Access bypass via spoofed Service Worker response URL
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A compromised renderer can spoof the final response URL of a dedicated or shared worker by providing a malicious URL list in a Service Worker FetchAPIResponse. Because the browser lacks a CanCommitURL validation for this URL, it can be exploited to derive a kLoopback IP address space. This potentially allows the worker to bypass Local Network Access (LNA) restrictions and spoof its identity in the browser UI.
Affected files:
content/browser/worker_host/dedicated_worker_host.cccontent/browser/worker_host/shared_worker_host.cccontent/browser/worker_host/shared_worker_service_impl.cccontent/browser/worker_host/worker_script_fetcher.ccthird_party/blink/common/service_worker/service_worker_loader_helpers.cc
Estimated timestamp from git blame: 2024-05-07
Description
There is a potential vulnerability in the initialization of Dedicated and Shared Workers when their main script fetch is intercepted by a Service Worker.
When a Service Worker responds to a fetch event, the renderer process sends a FetchAPIResponse IPC to the browser. The url_list field in this response is copied without validation and used to determine the worker’s final_response_url.
Crucially, the browser process (in DedicatedWorkerHost::DidStartScriptLoad and SharedWorkerServiceImpl::DidStartScriptLoad) fails to perform a ChildProcessSecurityPolicy::CanCommitURL check on this provided URL. This omission is explicitly noted in the codebase as TODO(crbug.com/41471904).
If an attacker provides a file:// or chrome:// URL, the worker adopts this URL. Under specific conditions—such as when a Service Worker registration is restored from an older database and its policy_container_host is null (documented in crbug.com/339200481)—the browser falls back to calculating the IP Address Space from the spoofed URL’s scheme. This causes the worker to be assigned the kLoopback address space, entirely bypassing Local Network Access (LNA) restrictions for subsequent subresource fetches. Additionally, the spoofed URL is broadcasted to Chrome’s Task Manager, DevTools, and COEP reports, leading to UI spoofing.
Note: Our tooling agent has identified this via static analysis. The following are potential steps to trigger the issue, as we do not yet have a working, executed proof of concept.
Potential Steps to Reproduce
- Renderer Compromise: An attacker exploits a vulnerability in a renderer process hosting
https://attacker.comand an active Service Worker. - Trigger Edge Case: The attacker triggers a state where the Service Worker’s
policy_container_hostis null (e.g., by forcing the browser to restore the registration from an older database profile). - Worker Creation: The compromised renderer executes JavaScript to spawn a new Dedicated Worker:
new Worker('worker.js'). - Service Worker Interception: The browser routes the
worker.jsfetch request to the compromised renderer’s Service Worker. - Spoofing the Response: The compromised renderer crafts a malicious
FetchAPIResponseIPC message, setting theurl_listto contain a highly privileged URL likefile:///etc/passwdorchrome://settings. - Missing Validation: In the browser process,
ServiceWorkerLoaderHelpers::SaveResponseInfocopies this unvalidated list.WorkerScriptFetcher::DetermineFinalResponseUrlextracts the spoofedfile://URL. - Address Space Calculation:
DedicatedWorkerHost::DidStartScriptLoaduses this URL to calculate the worker’s IP address space. Because the Service Worker’s policy container is null,CalculateIPAddressSpacefalls back to URL scheme derivation. - LNA Bypass: The
file://scheme causes the network service to returnkLoopback(services/network/public/cpp/ip_address_space_util.cc:432). The worker is granted loopback privileges. - Exploitation: The attacker-controlled worker executes a
fetch()tohttp://127.0.0.1:8080. TheLocalNetworkAccessCheckerobserves both the source and target address spaces askLoopbackand allows the request, bypassing LNA protections.
Code Pointers
third_party/blink/common/service_worker/service_worker_loader_helpers.cc:120: The unvalidatedurl_listis copied.content/browser/worker_host/worker_script_fetcher.cc:670-672: The last URL in the list is adopted as thefinal_response_url.content/browser/worker_host/dedicated_worker_host.cc:439-441: Thefinal_response_urlis saved without validation. An explicitTODO(crbug.com/41471904): Check if the main script's final response URL is committableis present here.content/browser/renderer_host/local_network_access_util.cc:296-314: The IP Address space falls back to URL scheme derivation if the Service Worker’s inherited address space is missing or unknown.
Suggested Fix
Implement the CanCommitURL validation check inside DedicatedWorkerHost::DidStartScriptLoad and SharedWorkerServiceImpl::DidStartScriptLoad as noted in crbug.com/41471904. If the renderer provides a final response URL that it does not have permission to commit, the browser process should terminate the renderer (e.g., via bad_message::ReceivedBadMessage) and abort the worker load.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from 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.