CVE-2026-87557
Overview
Background
- PlzWorker
- The browser-process-driven worker script loading model in which the browser fetches a worker’s main script and then hands it to the renderer, mirroring PlzNavigate for navigations.
- `CanCommitURL` / `ChildProcessSecurityPolicyImpl`
- The browser-side authorization service whose
CanCommitURLasks whether a given renderer process is permitted to host (commit) a given URL, and whoseGrantCommitURLexplicitly grants that permission. - Final response URL
- The URL adopted as a worker’s script/origin after the fetch completes (
result->final_response_url), which can be influenced by renderer-supplied data such as a service worker’surl_list_via_service_worker. - Worker storage key origin
- The origin the browser expects the worker to run under (
worker_storage_key_.origin()/worker_storage_key().origin()), used here to decide when a same-origin commit grant is safe.
Root Cause Analysis
The worker-script-load completion handlers in DedicatedWorkerHost and SharedWorkerServiceImpl adopted result->final_response_url as the worker’s committed URL (final_response_url_) and notified observers of it, with no check that the worker’s renderer process was actually authorized to commit that URL — the removed code was only a TODO(crbug.com/41471904) placeholder. Because the final response URL can be derived from renderer-controllable data (notably a service worker’s url_list_via_service_worker), a renderer could steer the browser into committing a worker under an arbitrary URL such as file:///..., violating the site-isolation invariant that a process only ever commits URLs it is permitted to host.
The fix inserts a CanCommitURL authorization gate before the URL is adopted: if the check fails, the dedicated-worker path calls ScriptLoadStartFailed(net::ERR_ABORTED) and returns, and the shared-worker path calls DestroyHost(host.get()) and returns, in both cases before final_response_url_ is set or observers are notified. A same-origin GrantCommitURL allowance is added first so legitimate cases (Isolated Web Apps, extensions) where the URL matches the worker’s storage-key origin still succeed. Relatedly, worker_script_fetcher.cc now passes ChildProcessHost::kInvalidUniqueID (instead of the real worker_process_id) when building the filesystem URLLoaderFactory for the main resource, so that factory no longer confers commit access and permissions are deferred to the new CanCommitURL check.
The patch therefore tightens authorization.
CanCommitURL gate — with a narrow same-origin GrantCommitURL exception — before the URL is ever adopted.Attack Path
- Control the worker main-script response
From a renderer it controls, an attacker arranges for the worker’s main script fetch to be answered by a service worker (or otherwise renderer-influenced response), which the test models via
was_fetched_via_service_workerandurl_list_via_service_worker. - Supply a non-committable final URL
The response’s service-worker URL list resolves to a URL the worker process is not permitted to commit (the test uses
file:///non_committable_path), which becomesresult->final_response_url. - Browser adopts the URL unchecked (pre-patch)
The completion handler sets
final_response_url_and callsNotifyWorkerFinalResponseURLDeterminedwithout anyCanCommitURLcheck, committing the worker under a URL/origin it should not be allowed to host. - Authorization boundary bypassed
The worker is thereby associated with an origin/URL the process was never authorized for, defeating the missing-authorization boundary the advisory attributes to
LocalNetworkAccess; any further capability gained from the mislabeled origin is (inferred).
Impact Assessment
LocalNetworkAccess. The precondition is the ability to control the worker main-script response (e.g., via a service worker’s provided-response URL list) for a dedicated or shared worker. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3. Any escalation beyond the mislabeled commit is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/worker_host/worker_script_fetcher.cc |
modified | |
WorkerFinalResponseURLObservercontent/browser/worker_host/worker_browsertest.cc |
modified | |
WorkerTestcontent/browser/worker_host/worker_browsertest.cc |
modified | |
ifcontent/browser/worker_host/worker_browsertest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fcontent/browser/worker_host/worker_browsertest.cc |
modified |
Files Changed
content/browser/worker_host/worker_browsertest.cccontent/browser/file_system/file_system_url_loader_factory.hcontent/browser/worker_host/shared_worker_service_impl.cccontent/browser/worker_host/dedicated_worker_host.cccontent/browser/worker_host/worker_script_fetcher.cc
Audit Directions
- Renderer-influenced URLs adopted without `CanCommitURL`Audit every browser-side site where a URL derived from renderer/service-worker data (URL lists, redirect chains, response heads) is stored as a process’s committed origin/URL; require a
CanCommitURLgate before adoption. - `URLLoaderFactory` created with a real process ID for browser-initiated fetchesLook for factories built with a valid
worker_process_id/render_process_idon browser-initiated main-resource paths, which silently confers commit authority; these should usekInvalidUniqueIDand defer to an explicit check. - Unresolved authorization TODOs on worker/navigation load pathsSearch for
TODO(crbug.com/41471904)-style placeholders and similar deferred “check if committable” comments in PlzWorker/PlzNavigate commit paths, which mark known-missing authorization checks.
Patch
From 0db5072577f34c19aedb711734737d8e6540899b Mon Sep 17 00:00:00 2001
From: Zainab Rizvi <rizvis@google.com>
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 <rizvis@google.com>
Reviewed-by: Hiroki Nakagawa <nhiroki@chromium.org>
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_script_fetcher.cc b/content/browser/worker_host/worker_script_fetcher.cc
index 6f67f02..4849ef4 100644
--- a/content/browser/worker_host/worker_script_fetcher.cc
+++ b/content/browser/worker_host/worker_script_fetcher.cc
@@ -32,6 +32,7 @@
#include "content/browser/worker_host/worker_script_loader_factory.h"
#include "content/public/browser/browser_context.h"
#include "content/public/browser/browser_thread.h"
+#include "content/public/browser/child_process_host.h"
#include "content/public/browser/content_browser_client.h"
#include "content/public/browser/global_request_id.h"
#include "content/public/browser/network_service_instance.h"
@@ -619,13 +620,13 @@
non_network_factories.emplace(url::kDataScheme,
DataURLLoaderFactory::Create());
if (filesystem_url_support) {
- // TODO(crbug.com/41471904): Pass ChildProcessHost::kInvalidUniqueID
- // instead of valid `worker_process_id` for `factory_bundle_for_browser`
- // once CanCommitURL-like check is implemented in PlzWorker.
+ int process_id = loader_type == LoaderType::kMainResource
+ ? ChildProcessHost::kInvalidUniqueID
+ : worker_process_id;
non_network_factories.emplace(
url::kFileSystemScheme,
CreateFileSystemURLLoaderFactory(
- worker_process_id, FrameTreeNodeId(),
+ process_id, FrameTreeNodeId(),
storage_partition->GetFileSystemContext(), storage_domain,
request_initiator_storage_key));
}
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()));
}
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.