Medium watch CVSS 4.3 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Workers
DescriptionRace condition in Workers
ComponentWorkers
Sub-componentWorker
Bug ClassRace
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker497839983
Fix commitbde3d8e65bd0 (chromium/src) +152/-80
CWECWE-367 (TOCTOU race)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-30 (per vendor advisory)
Fix landed2026-07-07 (public git)
Disclosed2026-08-25
Patch gap49 days — fix public in source before the advisory

Background

`DedicatedWorkerHost`
the browser-process object representing a dedicated worker, which brokers capability requests (WebNN, compute pressure, sockets, USB, etc.) on behalf of the worker.
`GlobalRenderFrameHostId`
a routing identifier for a RenderFrameHost; with RenderDocument, one RenderFrameHost (and thus one id) can be reused across same-site navigations to host different documents over time.
`WeakDocumentPtr`
a weak reference tied to a specific document; AsRenderFrameHostIfValid() yields the frame only while it still hosts that exact document, and null otherwise.
Ancestor frame
the RenderFrameHost that owns the worker (directly, or indirectly through a chain of nested dedicated workers) and whose permissions policy / isolation info governs the worker’s capabilities.

Root Cause Analysis

DedicatedWorkerHost stored its owning frame as a GlobalRenderFrameHostId (ancestor_render_frame_host_id_) and re-resolved it on every capability request via RenderFrameHostImpl::FromID. Under RenderDocument, a same-site navigation can reuse the same RenderFrameHost for a new document, so that stored id later resolves to a different document than the one that created the worker. Consequently, callers such as the WebNN binder (IsWebNNPermissionsPolicyBlocked) and the compute-pressure service consulted the replacement document’s permissions policy and isolation info instead of the creator’s, violating the invariant that a worker’s capabilities are governed by its creating document for its entire lifetime.

The fix records the creator’s document as a WeakDocumentPtr ancestor_document_ at host (and factory) creation time and adds GetAncestorRenderFrameHost(), which returns the frame only while it still hosts that document via AsRenderFrameHostIfValid(). All call sites are routed through this helper (and GetAncestorRenderFrameHostId() is derived from it), so once the creator document is gone the helper returns null and the request is dropped regardless of RenderDocument level.

Key insight
The mistake was identifying the owning frame by a reusable routing id rather than by document identity, so a same-site RFH reuse let a worker inherit a stranger document’s policy; the fix pins the owner to a WeakDocumentPtr and resolves it only while that exact document is still hosted.

Attack Path

  1. Create a worker from document A A page (document A) in a frame spawns a dedicated worker, whose DedicatedWorkerHost records the ancestor frame by GlobalRenderFrameHostId.
  2. Reuse the RFH for document B A same-site navigation that reuses the RenderFrameHost commits document B, so the stored id now resolves to B (the regression test AncestorLookupAfterSameSiteNavigation reproduces exactly this by disabling the proactive BrowsingInstance swap).
  3. Issue a capability request from the worker The still-alive worker requests a gated capability (e.g. WebNN, compute pressure, or another brokered interface); pre-patch, the host resolves the ancestor by id to document B.
  4. Evaluate against the wrong document The request is checked against document B’s permissions policy / isolation info instead of document A’s, letting the worker act under a document that did not create it (inferred that this yields access or data the creator document was not entitled to).

Impact Assessment

A web page can cause a dedicated worker’s capability and isolation decisions to be evaluated against a replacement document rather than its creator, a same-site security-policy confusion that can grant the worker permissions or network-isolation context belonging to a different document. Preconditions are a RenderDocument level that allows a RenderFrameHost to be reused for a same-site navigation and a worker that outlives that navigation; the advisory classifies this as a Race condition in Workers and does not report in-the-wild exploitation, with any concrete capability escalation being (inferred).

Changed Functions

FunctionChangeNotes
if
content/browser/worker_host/dedicated_worker_host.cc
modified

Files Changed

  • content/browser/worker_host/dedicated_worker_host_factory_impl.h
  • content/browser/worker_host/dedicated_worker_service_impl_unittest.cc
  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/worker_host/dedicated_worker_host.h
  • content/browser/compute_pressure/pressure_service_for_worker_unittest.cc
  • content/browser/browser_interface_binders.cc
  • content/browser/compute_pressure/pressure_service_for_dedicated_worker.cc
  • content/browser/worker_host/dedicated_worker_host.cc
  • content/browser/worker_host/dedicated_worker_host_factory_impl.cc

Audit Directions

  • Identity by reusable id
    Audit long-lived browser-process objects that cache a GlobalRenderFrameHostId (or other routing id) and re-resolve it later; under RenderDocument these can silently rebind to a new document.
  • Cross-navigation ownership
    Review worker, service, and helper objects that outlive frame navigations to confirm their policy/isolation decisions are keyed to a document token or WeakDocumentPtr, not a frame id.
  • Capability brokers
    Check every interface binder that gates a feature on the “ancestor” or “owning” frame to ensure it drops the request when the creating document is gone rather than falling through to whatever document currently occupies the frame.
From bde3d8e65bd0936d94c1e71fa9f9363481626ef6 Mon Sep 17 00:00:00 2001
From: Yoshisto Yanagisawa <[email protected]>
Date: Mon, 06 Jul 2026 23:06:21 -0700
Subject: [PATCH] [worker] Bind DedicatedWorkerHost to its creator document

DedicatedWorkerHost resolved its owning frame purely by
GlobalRenderFrameHostId on every capability request. When a RenderFrameHost
is reused for a same-site navigation that id resolves to a different
document, so the worker could consult the new document's permissions policy
and isolation info instead of the document that created it.

Record the ancestor frame's DocumentToken when the host (and its factory)
is created and add GetAncestorRenderFrameHost(), which only returns the
frame while it still hosts that document. All call sites, including the
WebNN binder and the compute-pressure service, now go through this helper
so requests are dropped once the creator document is gone, regardless of
the RenderDocument level.

TAG=agy
CONV=f7ad542f-e336-41b5-945a-a9878047820e

Bug: 497839983
Change-Id: I652a36a575f69dcf62006512f3e3990d6dd41475
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8017904
Reviewed-by: Hidehiko Abe <[email protected]>
Commit-Queue: Yoshisato Yanagisawa <[email protected]>
Reviewed-by: Rakina Zata Amni <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1657696}
---
diff --git a/content/browser/browser_interface_binders.cc b/content/browser/browser_interface_binders.cc
index 03864a2..73a3181 100644
--- a/content/browser/browser_interface_binders.cc
+++ b/content/browser/browser_interface_binders.cc
@@ -329,8 +329,7 @@
 template <typename WorkerHost>
 bool IsWebNNPermissionsPolicyBlocked(WorkerHost* host) {
   if constexpr (std::is_same_v<WorkerHost, DedicatedWorkerHost>) {
-    auto* ancestor_render_frame_host =
-        RenderFrameHostImpl::FromID(host->GetAncestorRenderFrameHostId());
+    auto* ancestor_render_frame_host = host->GetAncestorRenderFrameHost();
     return !ancestor_render_frame_host ||
            !ancestor_render_frame_host->IsFeatureEnabled(
                network::mojom::PermissionsPolicyFeature::kWebNN);
diff --git a/content/browser/compute_pressure/pressure_service_for_dedicated_worker.cc b/content/browser/compute_pressure/pressure_service_for_dedicated_worker.cc
index 67fec718..1f9e32d 100644
--- a/content/browser/compute_pressure/pressure_service_for_dedicated_worker.cc
+++ b/content/browser/compute_pressure/pressure_service_for_dedicated_worker.cc
@@ -25,9 +25,7 @@
 
   // https://www.w3.org/TR/compute-pressure/#dfn-owning-document-set
   // https://www.w3.org/TR/compute-pressure/#dfn-may-receive-data
-  auto* rfh =
-      RenderFrameHostImpl::FromID(worker_host_->GetAncestorRenderFrameHostId());
-  return HasImplicitFocus(rfh);
+  return HasImplicitFocus(worker_host_->GetAncestorRenderFrameHost());
 }
 
 std::optional<base::UnguessableToken>
@@ -35,9 +33,8 @@
     device::mojom::PressureSource source) const {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
 
-  const auto* web_contents =
-      WebContents::FromRenderFrameHost(RenderFrameHostImpl::FromID(
-          worker_host_->GetAncestorRenderFrameHostId()));
+  const auto* web_contents = WebContents::FromRenderFrameHost(
+      worker_host_->GetAncestorRenderFrameHost());
   if (const auto* pressure_manager_proxy =
           WebContentsPressureManagerProxy::FromWebContents(web_contents)) {
     return pressure_manager_proxy->GetTokenFor(source);
@@ -48,8 +45,7 @@
 RenderFrameHost* PressureServiceForDedicatedWorker::GetRenderFrameHost() const {
   DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
 
-  return RenderFrameHostImpl::FromID(
-      worker_host_->GetAncestorRenderFrameHostId());
+  return worker_host_->GetAncestorRenderFrameHost();
 }
 
 }  // namespace content
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 43c02647..5854915 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -14877,8 +14877,7 @@
   mojo::MakeSelfOwnedReceiver(
       std::make_unique<DedicatedWorkerHostFactoryImpl>(
           worker_process_id,
-          /*creator=*/GetGlobalId(),
-          /*ancestor_render_frame_host_id=*/GetGlobalId(), GetStorageKey(),
+          /*creator=*/GetGlobalId(), GetWeakDocumentPtr(), GetStorageKey(),
           isolation_info_, BuildClientSecurityState(),
           policy_container_host()->policies(),
           /*creator_coep_reporter=*/coep_reporter, GetNetworkRestrictionsID()),
diff --git a/content/browser/worker_host/dedicated_worker_host.cc b/content/browser/worker_host/dedicated_worker_host.cc
index fceaa928..656b953 100644
--- a/content/browser/worker_host/dedicated_worker_host.cc
+++ b/content/browser/worker_host/dedicated_worker_host.cc
@@ -80,7 +80,7 @@
     const blink::DedicatedWorkerToken& token,
     RenderProcessHost* worker_process_host,
     DedicatedWorkerCreator creator,
-    GlobalRenderFrameHostId ancestor_render_frame_host_id,
+    WeakDocumentPtr ancestor_document,
     const url::Origin& creator_origin,
     const blink::StorageKey& worker_storage_key,
     const url::Origin& renderer_origin,
@@ -95,7 +95,7 @@
       token_(token),
       worker_process_host_(worker_process_host),
       creator_(creator),
-      ancestor_render_frame_host_id_(ancestor_render_frame_host_id),
+      ancestor_document_(std::move(ancestor_document)),
       creator_origin_(creator_origin),
       worker_storage_key_(worker_storage_key),
       renderer_origin_(renderer_origin),
@@ -129,8 +129,7 @@
 
   service_->NotifyWorkerCreated(this);
 
-  auto* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+  auto* ancestor_render_frame_host = GetAncestorRenderFrameHost();
   if (ancestor_render_frame_host) {
     DedicatedWorkerHostsForDocument::GetOrCreateForCurrentDocument(
         ancestor_render_frame_host)
@@ -156,13 +155,7 @@
   // or RenderProcessHostObserver. This destruction should be called before
   // the observed render process host (`worker_process_host_`) is destroyed.
 
-  // The frame's current document might no longer be related to this worker. In
-  // this case, the previous DedicatedWorkerHostsForDocument has been deleted
-  // and calling Remove(...)` on the new one is a no-op. Note that when the
-  // previous document is BFCached and not deleted, the RenderFrameHost will
-  // never be reused, so we will always get the right (BFCached) document.
-  auto* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+  auto* ancestor_render_frame_host = GetAncestorRenderFrameHost();
   if (ancestor_render_frame_host) {
     DedicatedWorkerHostsForDocument::GetOrCreateForCurrentDocument(
         ancestor_render_frame_host)
@@ -189,6 +182,17 @@
   WorkerDevToolsManager::GetInstance().WorkerDestroyed(this);
 }
 
+GlobalRenderFrameHostId DedicatedWorkerHost::GetAncestorRenderFrameHostId()
+    const {
+  RenderFrameHost* rfh = ancestor_document_.AsRenderFrameHostIfValid();
+  return rfh ? rfh->GetGlobalId() : GlobalRenderFrameHostId();
+}
+
+RenderFrameHostImpl* DedicatedWorkerHost::GetAncestorRenderFrameHost() const {
+  return RenderFrameHostImpl::From(
+      ancestor_document_.AsRenderFrameHostIfValid());
+}
+
 void DedicatedWorkerHost::BindBrowserInterfaceBrokerReceiver(
     mojo::PendingReceiver<blink::mojom::BrowserInterfaceBroker> receiver) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
@@ -201,16 +205,15 @@
 void DedicatedWorkerHost::CreateContentSecurityNotifier(
     mojo::PendingReceiver<blink::mojom::ContentSecurityNotifier> receiver) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
-  auto* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+  auto* ancestor_render_frame_host = GetAncestorRenderFrameHost();
   if (!ancestor_render_frame_host) {
     // The ancestor frame may have already been closed. In that case, the worker
     // will soon be terminated too, so abort the connection.
     return;
   }
-  mojo::MakeSelfOwnedReceiver(
-      std::make_unique<ContentSecurityNotifier>(ancestor_render_frame_host_id_),
-      std::move(receiver));
+  mojo::MakeSelfOwnedReceiver(std::make_unique<ContentSecurityNotifier>(
+                                  ancestor_render_frame_host->GetGlobalId()),
+                              std::move(receiver));
 }
 
 void DedicatedWorkerHost::CreateLockManager(
@@ -223,7 +226,7 @@
 
 bool DedicatedWorkerHost::OnLockContention() {
   RenderFrameHostImpl* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+      GetAncestorRenderFrameHost();
   if (!ancestor_render_frame_host) {
     // The frame may have already been closed.
     return false;
@@ -301,7 +304,7 @@
   // Get nearest ancestor RenderFrameHost in order to determine the
   // top-frame origin to use for the network isolation key.
   RenderFrameHostImpl* nearest_ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+      GetAncestorRenderFrameHost();
   if (!nearest_ancestor_render_frame_host) {
     ScriptLoadStartFailed(network::URLLoaderCompletionStatus(net::ERR_ABORTED));
     return;
@@ -449,7 +452,7 @@
   // TODO(cammie): Change this approach when we support shared workers
   // creating dedicated workers, as there might be no ancestor frame.
   RenderFrameHostImpl* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+      GetAncestorRenderFrameHost();
   if (!ancestor_render_frame_host) {
     ScriptLoadStartFailed(network::URLLoaderCompletionStatus(net::ERR_ABORTED));
     return;
@@ -619,8 +622,7 @@
 
 void DedicatedWorkerHost::ScriptLoadStartFailed(
     const network::URLLoaderCompletionStatus& status) {
-  auto* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+  auto* ancestor_render_frame_host = GetAncestorRenderFrameHost();
   if (ancestor_render_frame_host) {
     // Notify that the loading failed to DevTools. It fires
     // `Network.onLoadingFailed` event.
@@ -765,7 +767,7 @@
     mojo::PendingReceiver<blink::mojom::DirectSocketsService> receiver) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   RenderFrameHostImpl* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+      GetAncestorRenderFrameHost();
   // The ancestor frame may have already been closed. In that case, the worker
   // will soon be terminated too, so abort the connection.
   if (!ancestor_render_frame_host) {
@@ -781,7 +783,7 @@
     mojo::PendingReceiver<blink::mojom::WebUsbService> receiver) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   RenderFrameHostImpl* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+      GetAncestorRenderFrameHost();
   // The ancestor frame may have already been closed. In that case, the worker
   // will soon be terminated too, so abort the connection.
   if (!ancestor_render_frame_host) {
@@ -795,7 +797,7 @@
     mojo::PendingReceiver<blink::mojom::WebSocketConnector> receiver) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   RenderFrameHostImpl* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+      GetAncestorRenderFrameHost();
   if (!ancestor_render_frame_host) {
     // The ancestor frame may have already been closed. In that case, the worker
     // will soon be terminated too, so abort the connection.
@@ -805,9 +807,7 @@
   // TODO(crbug.com/379869738) Remove GetUnsafeValue.
   mojo::MakeSelfOwnedReceiver(
       std::make_unique<WebSocketConnectorImpl>(
-          GlobalRenderFrameHostId(
-              ancestor_render_frame_host_id_.child_id,
-              ancestor_render_frame_host_id_.frame_routing_id),
+          ancestor_render_frame_host->GetGlobalId(),
           GetWorkerStorageKey().origin(),
           ancestor_render_frame_host->GetIsolationInfoForSubresources(),
           worker_client_security_state_->Clone(), network_restrictions_id_),
@@ -818,7 +818,7 @@
     mojo::PendingReceiver<blink::mojom::WebTransportConnector> receiver) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   RenderFrameHostImpl* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+      GetAncestorRenderFrameHost();
   if (!ancestor_render_frame_host) {
     // The ancestor frame may have already been closed. In that case, the worker
     // will soon be terminated too, so abort the connection.
@@ -862,10 +862,10 @@
 
   mojo::MakeSelfOwnedReceiver(
       std::make_unique<DedicatedWorkerHostFactoryImpl>(
-          worker_process_host_->GetID(), /*creator=*/token_,
-          ancestor_render_frame_host_id_, GetWorkerStorageKey(),
-          isolation_info_, worker_client_security_state_->Clone(),
-          creator_policies_, creator_coep_reporter, network_restrictions_id_),
+          worker_process_host_->GetID(), /*creator=*/token_, ancestor_document_,
+          GetWorkerStorageKey(), isolation_info_,
+          worker_client_security_state_->Clone(), creator_policies_,
+          creator_coep_reporter, network_restrictions_id_),
       std::move(receiver));
 }
 
@@ -873,7 +873,7 @@
     mojo::PendingReceiver<blink::mojom::IdleManager> receiver) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   RenderFrameHostImpl* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+      GetAncestorRenderFrameHost();
   if (!ancestor_render_frame_host) {
     // The ancestor frame may have already been closed. In that case, the worker
     // will soon be terminated too, so abort the connection.
@@ -952,7 +952,7 @@
     mojo::PendingReceiver<blink::mojom::SerialService> receiver) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   RenderFrameHostImpl* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+      GetAncestorRenderFrameHost();
   if (!ancestor_render_frame_host) {
     // The ancestor frame may have already been closed. In that case, the worker
     // will soon be terminated too, so abort the connection.
@@ -967,7 +967,7 @@
     mojo::PendingReceiver<blink::mojom::HidService> receiver) {
   DCHECK_CURRENTLY_ON(BrowserThread::UI);
   RenderFrameHostImpl* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+      GetAncestorRenderFrameHost();
   // The ancestor frame may have already been closed. In that case, the worker
   // will soon be terminated too, so abort the connection.
   if (!ancestor_render_frame_host) {
@@ -1011,8 +1011,7 @@
   }
 
   // https://www.w3.org/TR/compute-pressure/#policy-control
-  auto* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+  auto* ancestor_render_frame_host = GetAncestorRenderFrameHost();
   if (!ancestor_render_frame_host) {
     return;
   }
@@ -1076,7 +1075,7 @@
   auto* storage_partition_impl = GetStoragePartitionImpl();
 
   RenderFrameHostImpl* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+      GetAncestorRenderFrameHost();
   if (!ancestor_render_frame_host) {
     return;
   }
@@ -1124,7 +1123,7 @@
     blink::mojom::RendererEvictionReason reason,
     blink::mojom::ScriptSourceLocationPtr source) {
   RenderFrameHostImpl* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+      GetAncestorRenderFrameHost();
   if (!ancestor_render_frame_host) {
     // The frame may have already been closed.
     return;
@@ -1136,7 +1135,7 @@
 void DedicatedWorkerHost::DidChangeBackForwardCacheDisablingFeatures(
     BackForwardCacheBlockingDetails details) {
   RenderFrameHostImpl* ancestor_render_frame_host =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+      GetAncestorRenderFrameHost();
   if (!ancestor_render_frame_host) {
     // The frame may have already been closed.
     return;
@@ -1196,8 +1195,7 @@
 }
 
 storage::BucketClientInfo DedicatedWorkerHost::GetBucketClientInfo() const {
-  const auto* ancestor_rfh =
-      RenderFrameHostImpl::FromID(ancestor_render_frame_host_id_);
+  const auto* ancestor_rfh = GetAncestorRenderFrameHost();
   return storage::BucketClientInfo{
       worker_process_host_->GetDeprecatedID(), GetToken(),
       ancestor_rfh ? std::optional(ancestor_rfh->GetDocumentToken())
diff --git a/content/browser/worker_host/dedicated_worker_host.h b/content/browser/worker_host/dedicated_worker_host.h
index 3feb1957..87227f7 100644
--- a/content/browser/worker_host/dedicated_worker_host.h
+++ b/content/browser/worker_host/dedicated_worker_host.h
@@ -24,6 +24,7 @@
 #include "content/public/browser/global_routing_id.h"
 #include "content/public/browser/render_process_host.h"
 #include "content/public/browser/render_process_host_observer.h"
+#include "content/public/browser/weak_document_ptr.h"
 #include "mojo/public/cpp/bindings/pending_receiver.h"
 #include "mojo/public/cpp/bindings/pending_remote.h"
 #include "mojo/public/cpp/bindings/receiver.h"
@@ -112,7 +113,7 @@
       const blink::DedicatedWorkerToken& token,
       RenderProcessHost* worker_process_host,
       DedicatedWorkerCreator creator,
-      GlobalRenderFrameHostId ancestor_render_frame_host_id,
+      WeakDocumentPtr ancestor_document,
       const url::Origin& creator_origin,
       const blink::StorageKey& worker_storage_key,
       const url::Origin& renderer_origin,
@@ -137,9 +138,10 @@
   const blink::StorageKey& GetWorkerStorageKey() const {
     return worker_storage_key_;
   }
-  const GlobalRenderFrameHostId& GetAncestorRenderFrameHostId() const {
-    return ancestor_render_frame_host_id_;
-  }
+  GlobalRenderFrameHostId GetAncestorRenderFrameHostId() const;
+  // Returns the ancestor RenderFrameHost if it still hosts the document that
+  // created this worker, or nullptr otherwise.
+  RenderFrameHostImpl* GetAncestorRenderFrameHost() const;
   DedicatedWorkerCreator GetCreator() const { return creator_; }
   const std::optional<GURL>& GetFinalResponseURL() const {
     return final_response_url_;
@@ -358,9 +360,9 @@
   // worker is nested.
   const DedicatedWorkerCreator creator_;
 
-  // The ID of the frame that owns this worker, either directly, or (in the case
-  // of nested workers) indirectly via a tree of dedicated workers.
-  const GlobalRenderFrameHostId ancestor_render_frame_host_id_;
+  // The document that owns this worker, either directly, or (in the case of
+  // nested workers) indirectly via a tree of dedicated workers.
+  const WeakDocumentPtr ancestor_document_;
 
   // The origin of the frame or dedicated worker that starts this worker.
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/compute_pressure/pressure_service_for_worker_unittest.cc b/content/browser/compute_pressure/pressure_service_for_worker_unittest.cc
index c33a0461..cf70bdc 100644
--- a/content/browser/compute_pressure/pressure_service_for_worker_unittest.cc
+++ b/content/browser/compute_pressure/pressure_service_for_worker_unittest.cc
@@ -20,6 +20,7 @@
 #include "content/browser/worker_host/shared_worker_service_impl.h"
 #include "content/browser/worker_host/worker_util.h"
 #include "content/public/browser/shared_worker_instance.h"
+#include "content/public/browser/weak_document_ptr.h"
 #include "content/public/test/navigation_simulator.h"
 #include "content/test/test_render_frame_host.h"
 #include "content/test/test_render_view_host.h"
@@ -147,9 +148,10 @@
     CHECK_EQ(rfh->GetLastCommittedOrigin(), rfh->GetStorageKey().origin());
     worker_host_ = std::make_unique<DedicatedWorkerHost>(
         &worker_service_, blink::DedicatedWorkerToken(), rfh->GetProcess(),
-        rfh->GetGlobalId(), rfh->GetGlobalId(), rfh->GetStorageKey().origin(),
-        rfh->GetStorageKey(), rfh->GetStorageKey().origin(),
-        rfh->GetIsolationInfoForSubresources(), rfh->BuildClientSecurityState(),
+        rfh->GetGlobalId(), rfh->GetWeakDocumentPtr(),
+        rfh->GetStorageKey().origin(), rfh->GetStorageKey(),
+        rfh->GetStorageKey().origin(), rfh->GetIsolationInfoForSubresources(),
+        rfh->BuildClientSecurityState(),
         rfh->policy_container_host()->policies(),
         /*creator_coep_reporter=*/nullptr,
         network::GetTestNetworkRestrictionsId(),
diff --git a/content/browser/worker_host/dedicated_worker_service_impl_unittest.cc b/content/browser/worker_host/dedicated_worker_service_impl_unittest.cc
index 63329114..4b99649 100644
--- a/content/browser/worker_host/dedicated_worker_service_impl_unittest.cc
+++ b/content/browser/worker_host/dedicated_worker_service_impl_unittest.cc
@@ -11,14 +11,19 @@
 #include "base/run_loop.h"
 #include "base/scoped_observation.h"
 #include "base/test/scoped_feature_list.h"
+#include "components/services/storage/privileged/cpp/bucket_client_info.h"
+#include "content/browser/renderer_host/render_frame_host_impl.h"
 #include "content/browser/site_instance_impl.h"
 #include "content/browser/storage_partition_impl.h"
 #include "content/browser/worker_host/dedicated_worker_host.h"
 #include "content/browser/worker_host/dedicated_worker_host_factory_impl.h"
+#include "content/common/content_navigation_policy.h"
 #include "content/public/browser/storage_partition.h"
 #include "content/public/common/content_features.h"
 #include "content/public/test/navigation_simulator.h"
 #include "content/public/test/test_browser_context.h"
+#include "content/public/test/test_utils.h"
+#include "content/test/render_document_feature.h"
 #include "content/test/test_render_view_host.h"
 #include "content/test/test_web_contents.h"
 #include "mojo/public/cpp/bindings/remote.h"
@@ -60,7 +65,9 @@
     mojo::MakeSelfOwnedReceiver(
         std::make_unique<DedicatedWorkerHostFactoryImpl>(
             worker_process_id, /*creator=*/render_frame_host_id,
-            render_frame_host_id, blink::StorageKey::CreateFirstParty(origin),
+            RenderFrameHostImpl::FromID(render_frame_host_id)
+                ->GetWeakDocumentPtr(),
+            blink::StorageKey::CreateFirstParty(origin),
             net::IsolationInfo::CreateTransient(/*nonce=*/std::nullopt),
             network::mojom::ClientSecurityState::New(),
             PolicyContainerPolicies(), coep_reporter->GetWeakPtr(),
@@ -290,6 +297,71 @@
   EXPECT_TRUE(observer.dedicated_worker_infos().empty());
 }
 
+class DedicatedWorkerHostNavigationTest
+    : public DedicatedWorkerServiceImplTest {
+ public:
+  DedicatedWorkerHostNavigationTest() {
+    // Allow same-site navigations to reuse the RenderFrameHost so that the
+    // ancestor frame id can resolve to a different document.
+    InitAndEnableRenderDocumentFeature(
+        &feature_list_,
+        GetRenderDocumentLevelName(RenderDocumentLevel::kCrashedFrame));
+  }
+
+ private:
+  base::test::ScopedFeatureList feature_list_;
+};
+
+TEST_F(DedicatedWorkerHostNavigationTest,
+       AncestorLookupAfterSameSiteNavigation) {
+  TestDedicatedWorkerServiceObserver observer;
+  base::ScopedObservation<DedicatedWorkerService,
+                          DedicatedWorkerService::Observer>
+      scoped_observation(&observer);
+  scoped_observation.Observe(GetDedicatedWorkerService());
+
+  const GURL kUrlA("http://example.com/a.html");
+  std::unique_ptr<TestWebContents> web_contents = CreateWebContents(kUrlA);
+  RenderFrameHostImpl* render_frame_host = web_contents->GetPrimaryMainFrame();
+  const blink::DocumentToken creator_document_token =
+      render_frame_host->GetDocumentToken();
+
+  // Create a dedicated worker for the current document.
+  auto mock_dedicated_worker = std::make_unique<MockDedicatedWorker>(
+      render_frame_host->GetProcess()->GetID(),
+      render_frame_host->GetGlobalId(), url::Origin::Create(kUrlA));
+  observer.RunUntilWorkerEvent();
+  ASSERT_EQ(observer.dedicated_worker_infos().size(), 1u);
+  const blink::DedicatedWorkerToken worker_token =
+      observer.dedicated_worker_infos().begin()->first;
+  DedicatedWorkerHost* host =
+      static_cast<DedicatedWorkerServiceImpl*>(GetDedicatedWorkerService())
+          ->GetDedicatedWorkerHostFromToken(worker_token);
+  ASSERT_TRUE(host);
+  EXPECT_EQ(host->GetAncestorRenderFrameHost(), render_frame_host);
+  EXPECT_EQ(host->GetBucketClientInfo().document_token, creator_document_token);
+
+  // Commit a same-site navigation that reuses the RenderFrameHost so that it
+  // hosts a different document.
+  DisableProactiveBrowsingInstanceSwapFor(render_frame_host);
+  const GURL kUrlB("http://example.com/b.html");
+  NavigationSimulator::NavigateAndCommitFromDocument(kUrlB, render_frame_host);
+  ASSERT_EQ(web_contents->GetPrimaryMainFrame(), render_frame_host);
+  ASSERT_NE(render_frame_host->GetDocumentToken(), creator_document_token);
+  ASSERT_TRUE(
+      static_cast<DedicatedWorkerServiceImpl*>(GetDedicatedWorkerService())
+          ->GetDedicatedWorkerHostFromToken(worker_token));
+
+  // The worker host must not resolve its ancestor to the document that
+  // replaced its creator.
+  EXPECT_FALSE(host->GetAncestorRenderFrameHost());
+  EXPECT_NE(host->GetBucketClientInfo().document_token,
+            render_frame_host->GetDocumentToken());
+
+  mock_dedicated_worker = nullptr;
+  observer.RunUntilWorkerEvent();
+}
+
 class DedicatedWorkerHostFactoryImplTest
     : public RenderViewHostImplTestHarness {
  public:
@@ -332,7 +404,8 @@
         return std::make_unique<DedicatedWorkerHostFactoryImpl>(
             creator_rfh->GetProcess()->GetID(),
             static_cast<RenderFrameHostImpl*>(creator_rfh)->GetGlobalId(),
-            static_cast<RenderFrameHostImpl*>(creator_rfh)->GetGlobalId(),
+            static_cast<RenderFrameHostImpl*>(creator_rfh)
+                ->GetWeakDocumentPtr(),
             blink::StorageKey::CreateFirstParty(creator_origin),
             net::IsolationInfo::CreateTransient(std::nullopt),
             network::mojom::ClientSecurityState::New(),
@@ -483,7 +556,8 @@
           return std::make_unique<DedicatedWorkerHostFactoryImpl>(
               opaque_rfh->GetProcess()->GetID(),
               static_cast<RenderFrameHostImpl*>(opaque_rfh)->GetGlobalId(),
-              static_cast<RenderFrameHostImpl*>(opaque_rfh)->GetGlobalId(),
+              static_cast<RenderFrameHostImpl*>(opaque_rfh)
+                  ->GetWeakDocumentPtr(),
               blink::StorageKey::CreateFirstParty(kOpaqueOrigin),
               net::IsolationInfo::CreateTransient(std::nullopt),
               network::mojom::ClientSecurityState::New(),
Loading diff…

Original Bug Report

reported by [email protected]

Potential TOCTOU in DedicatedWorkerHost allows cross-document capability delegation

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: DedicatedWorkerHost performs live lookups of its ancestor RenderFrameHost using a GlobalRenderFrameHostId. Because RenderFrameHosts can be reused across same-site navigations, a worker from a previous document can inadvertently access capabilities or network contexts belonging to a newly navigated document.

Affected files:

  • content/browser/worker_host/dedicated_worker_host.cc
  • content/browser/worker_host/dedicated_worker_host.h

Estimated timestamp from git blame: 2024-09-18

Conclusion: A Time-of-Check Time-of-Use (TOCTOU) vulnerability exists in DedicatedWorkerHost, enabling Cross-Origin/Same-Origin Policy bypasses via cross-document capability delegation.

Analysis: Initial logic and architecture parameters are validated: DedicatedWorkerHost retains a GlobalRenderFrameHostId (ancestor_render_frame_host_id_) to its creator frame. It performs live lookups via RenderFrameHostImpl::FromID() to authorize capabilities like WebUSB, Direct Sockets, or subresource loading. RenderFrameHosts (RFHs) are frequently reused across same-site navigations, meaning the ID remains constant while the underlying Document, Origin, and PermissionsPolicy fundamentally change.

The vulnerability manifests during this RFH reuse. If a dedicated worker outlives its creating document (e.g., via a compromised renderer delaying Mojo pipe teardown, or an IPC race condition), subsequent capability requests are evaluated against the new document’s state.

Potential Attack Steps (Theoretical): Note: These are suggested steps; our tooling agent does not run code to provide a working PoC.

  1. A compromised renderer loads Document A (e.g., https://a.example.com, Permissions-Policy: usb=()) in an iframe and spawns a DedicatedWorker.
  2. The renderer initiates a same-site navigation in the iframe to Document B (e.g., https://b.example.com, Permissions-Policy: usb=*).
  3. The browser reuses the existing RenderFrameHost, updating its policy and origin to Document B. Document A’s DocumentUserData is destroyed, but the browser does not actively terminate the DedicatedWorkerHost as the renderer intentionally holds the Mojo pipe open.
  4. The worker from Document A sends an IPC requesting WebUsbService (or triggers a network factory rebuild via a simulated NetworkService crash).
  5. DedicatedWorkerHost::CreateWebUsbService looks up the RFH ID, retrieves Document B’s RFH, and validates the request against Document B’s permissive policy.
  6. The browser incorrectly grants WebUSB access (or Document B’s network context/cookies) to Document A’s worker.

Impacted Functions:

  • DedicatedWorkerHost::CreateWebUsbService
  • DedicatedWorkerHost::CreateWebSocketConnector
  • DedicatedWorkerHost::CreateDirectSocketsService
  • DedicatedWorkerHost::CreateNetworkFactoryForSubresources (leaks IsolationInfo and cookie overrides)

Suggested Fix: Modify DedicatedWorkerHost to store a blink::DocumentToken alongside the GlobalRenderFrameHostId at creation. During live lookups, verify that ancestor_render_frame_host->GetDocumentToken() matches the stored token. If they differ, the capability request must be safely aborted.

Evaluated with Chrome root at commit: 4859e669de60239572397b559c17dfab074511e9


Results 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.

View on issue tracker