CVE-2026-79094
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.cc |
modified |
Files Changed
content/browser/back_forward_cache_browsertest.ccthird_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.ccthird_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.h
Patch
From 8f32771f758cd25294e982568b81b2522cd8659d Mon Sep 17 00:00:00 2001 From: Test User <[email protected]> Date: Wed, 01 Jul 2026 18:25:16 -0700 Subject: [PATCH] Handle freeze/resume requests for dedicated workers before their thread initialization. Currently, when DedicatedWorkerMessagingProxy::Freeze() or Resume() is called before the worker thread is initialized, the request is ignored because GetWorkerThread() returns nullptr. To address this, this CL: - Introduces pending_freeze_is_in_back_forward_cache_ to store the requested freeze state when the worker thread is not yet available. - Applies the stored freeze state to the worker thread when it is started in StartWorkerGlobalScope(). - Resets the stored state if Resume() is called before thread initialization. - Adds a browser test to verify the freeze behavior for nested workers. Fixed: 517467117 Change-Id: I613ced93bd3c7a029570b49fa2194563f92fdd40 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7895623 Reviewed-by: Yoshisato Yanagisawa <[email protected]> Commit-Queue: Minoru Chikamune <[email protected]> Reviewed-by: Anna Sato <[email protected]> Reviewed-by: Rakina Zata Amni <[email protected]> Cr-Commit-Position: refs/heads/main@{#1655853} --- diff --git a/content/browser/back_forward_cache_browsertest.cc b/content/browser/back_forward_cache_browsertest.cc index 8b84b1f2..4e728d3 100644 --- a/content/browser/back_forward_cache_browsertest.cc +++ b/content/browser/back_forward_cache_browsertest.cc @@ -3731,6 +3731,92 @@ EXPECT_TRUE(rfh_c->IsHistoryUserActivationActive()); } +// Test that a nested Dedicated Worker created while the parent is waiting for +// its script fetch is correctly frozen when the page enters the Back-Forward +// Cache. +IN_PROC_BROWSER_TEST_F(BackForwardCacheBrowserTest, + BFCacheBypassViaUnfrozenNestedWorker) { + net::test_server::ControllableHttpResponse w2_response(embedded_test_server(), + "/w2.js"); + + ASSERT_TRUE(embedded_test_server()->Start()); + + GURL url_a(embedded_test_server()->GetURL("a.com", + "/back_forward_cache/empty.html")); + GURL url_b(embedded_test_server()->GetURL("a.com", "/title1.html")); + + // Navigate to A. + EXPECT_TRUE(NavigateToURL(shell(), url_a)); + RenderFrameHostImpl* rfh_a = current_frame_host(); + RenderFrameDeletedObserver deleted(rfh_a); + + std::string w2_url = embedded_test_server()->GetURL("a.com", "/w2.js").spec(); + + // Create w1, which creates w2. Also set up a promise on A to listen for + // the message from w2 after we restore the page. + std::string js_script = R"( + window.receivedMsg = new Promise(resolve => { + const bc = new BroadcastChannel('bfcache_channel'); + bc.onmessage = (e) => { + resolve(e.data); + }; + }); + const w1_code = ` + const w2 = new Worker(')" + + w2_url + R"('); + `; + const blob = new Blob([w1_code], {type: 'application/javascript'}); + const w1 = new Worker(URL.createObjectURL(blob)); + "done"; + )"; + EXPECT_TRUE(ExecJs(rfh_a, js_script)); + + // Wait for the request for w2.js to hit the server. + w2_response.WaitForRequest(); + + // Now w2 is pending fetch. Navigate to B to put A into BFCache. + EXPECT_TRUE(NavigateToURL(shell(), url_b)); + + // Ensure A is in BFCache. + EXPECT_FALSE(deleted.deleted()); + EXPECT_TRUE(rfh_a->IsInBackForwardCache()); + + // In active page B, set up a BroadcastChannel to verify the message is not + // received. + RenderFrameHostImpl* rfh_b = current_frame_host(); + EXPECT_TRUE(ExecJs(rfh_b, R"( + window.hasReceived = false; + const bc = new BroadcastChannel('bfcache_channel'); + bc.onmessage = (e) => { + window.hasReceived = true; + }; + )")); + + // Now respond to the w2.js request. + w2_response.Send( + "HTTP/1.1 200 OK\r\n" + "Content-Type: application/javascript\r\n" + "\r\n" + "const bc = new BroadcastChannel('bfcache_channel');\n" + "bc.postMessage('Hello from nested worker!');\n"); + w2_response.Done(); + + // Allow any outstanding tasks to execute on the renderer. + EXPECT_TRUE(ExecJs(rfh_b, "true")); + + // Since A is in BFCache, w2 should be frozen and NOT run or send messages. + EXPECT_FALSE(EvalJs(rfh_b, "window.hasReceived").ExtractBool()); + + // Go back to page A. + ASSERT_TRUE(HistoryGoBack(shell()->web_contents())); + ExpectRestored(FROM_HERE); + + // Once page A is restored (and unfrozen), w2 should resume and send the + // message. + std::string result = EvalJs(rfh_a, "window.receivedMsg").ExtractString(); + EXPECT_EQ("Hello from nested worker!", result); +} + // BEFORE ADDING A NEW TEST HERE // Read the note at the top about the other files you could add it to. } // namespace content diff --git a/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.cc b/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.cc index 4255301..4b30b69 100644 --- a/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.cc +++ b/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.cc @@ -116,6 +116,11 @@ return; } + if (pending_freeze_is_in_back_forward_cache_.has_value()) { + GetWorkerThread()->Freeze(*pending_freeze_is_in_back_forward_cache_); + pending_freeze_is_in_back_forward_cache_.reset(); + } + // Step 13: "Obtain script by switching on the value of options's type // member:" if (options->type() == V8WorkerType::Enum::kClassic) { @@ -216,16 +221,26 @@ void DedicatedWorkerMessagingProxy::Freeze(bool is_in_back_forward_cache) { DCHECK(IsParentContextThread()); auto* worker_thread = GetWorkerThread(); - if (AskedToTerminate() || !worker_thread) + if (AskedToTerminate()) { return; + } + if (!worker_thread) { + pending_freeze_is_in_back_forward_cache_ = is_in_back_forward_cache; + return; + } worker_thread->Freeze(is_in_back_forward_cache); } void DedicatedWorkerMessagingProxy::Resume() { DCHECK(IsParentContextThread()); auto* worker_thread = GetWorkerThread(); - if (AskedToTerminate() || !worker_thread) + if (AskedToTerminate()) { return; + } + if (!worker_thread) { + pending_freeze_is_in_back_forward_cache_.reset(); + return; + } worker_thread->Resume(); } diff --git a/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.h b/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.h index 1a76b79..5fdef33 100644 --- a/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.h +++ b/third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.h @@ -154,6 +154,9 @@ // Pauses virtual time in parent context until the worker is initialized. WebScopedVirtualTimePauser virtual_time_pauser_; + + // Tracks the freeze state when the worker thread is not yet created. + std::optional<bool> pending_freeze_is_in_back_forward_cache_; }; } // namespace blink
Regression Test / PoC
diff --git a/content/browser/back_forward_cache_browsertest.cc b/content/browser/back_forward_cache_browsertest.cc
index 8b84b1f2..4e728d3 100644
--- a/content/browser/back_forward_cache_browsertest.cc
+++ b/content/browser/back_forward_cache_browsertest.cc
@@ -3731,6 +3731,92 @@
EXPECT_TRUE(rfh_c->IsHistoryUserActivationActive());
}
+// Test that a nested Dedicated Worker created while the parent is waiting for
+// its script fetch is correctly frozen when the page enters the Back-Forward
+// Cache.
+IN_PROC_BROWSER_TEST_F(BackForwardCacheBrowserTest,
+ BFCacheBypassViaUnfrozenNestedWorker) {
+ net::test_server::ControllableHttpResponse w2_response(embedded_test_server(),
+ "/w2.js");
+
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ GURL url_a(embedded_test_server()->GetURL("a.com",
+ "/back_forward_cache/empty.html"));
+ GURL url_b(embedded_test_server()->GetURL("a.com", "/title1.html"));
+
+ // Navigate to A.
+ EXPECT_TRUE(NavigateToURL(shell(), url_a));
+ RenderFrameHostImpl* rfh_a = current_frame_host();
+ RenderFrameDeletedObserver deleted(rfh_a);
+
+ std::string w2_url = embedded_test_server()->GetURL("a.com", "/w2.js").spec();
+
+ // Create w1, which creates w2. Also set up a promise on A to listen for
+ // the message from w2 after we restore the page.
+ std::string js_script = R"(
+ window.receivedMsg = new Promise(resolve => {
+ const bc = new BroadcastChannel('bfcache_channel');
+ bc.onmessage = (e) => {
+ resolve(e.data);
+ };
+ });
+ const w1_code = `
+ const w2 = new Worker(')" +
+ w2_url + R"(');
+ `;
+ const blob = new Blob([w1_code], {type: 'application/javascript'});
+ const w1 = new Worker(URL.createObjectURL(blob));
+ "done";
+ )";
+ EXPECT_TRUE(ExecJs(rfh_a, js_script));
+
+ // Wait for the request for w2.js to hit the server.
+ w2_response.WaitForRequest();
+
+ // Now w2 is pending fetch. Navigate to B to put A into BFCache.
+ EXPECT_TRUE(NavigateToURL(shell(), url_b));
+
+ // Ensure A is in BFCache.
+ EXPECT_FALSE(deleted.deleted());
+ EXPECT_TRUE(rfh_a->IsInBackForwardCache());
+
+ // In active page B, set up a BroadcastChannel to verify the message is not
+ // received.
+ RenderFrameHostImpl* rfh_b = current_frame_host();
+ EXPECT_TRUE(ExecJs(rfh_b, R"(
+ window.hasReceived = false;
+ const bc = new BroadcastChannel('bfcache_channel');
+ bc.onmessage = (e) => {
+ window.hasReceived = true;
+ };
+ )"));
+
+ // Now respond to the w2.js request.
+ w2_response.Send(
+ "HTTP/1.1 200 OK\r\n"
+ "Content-Type: application/javascript\r\n"
+ "\r\n"
+ "const bc = new BroadcastChannel('bfcache_channel');\n"
+ "bc.postMessage('Hello from nested worker!');\n");
+ w2_response.Done();
+
+ // Allow any outstanding tasks to execute on the renderer.
+ EXPECT_TRUE(ExecJs(rfh_b, "true"));
+
+ // Since A is in BFCache, w2 should be frozen and NOT run or send messages.
+ EXPECT_FALSE(EvalJs(rfh_b, "window.hasReceived").ExtractBool());
+
+ // Go back to page A.
+ ASSERT_TRUE(HistoryGoBack(shell()->web_contents()));
+ ExpectRestored(FROM_HERE);
+
+ // Once page A is restored (and unfrozen), w2 should resume and send the
+ // message.
+ std::string result = EvalJs(rfh_a, "window.receivedMsg").ExtractString();
+ EXPECT_EQ("Hello from nested worker!", result);
+}
+
// BEFORE ADDING A NEW TEST HERE
// Read the note at the top about the other files you could add it to.
} // namespace content
Original Bug Report
BFCache freeze bypass for in-flight DedicatedWorkers allowing background JS execution
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential logic bug in DedicatedWorkerMessagingProxy::Freeze causes freeze requests to be silently dropped if the child WorkerThread has not yet been initialized. Since this state is not re-evaluated after the worker thread subsequently starts, and Mojo callbacks are dispatched on an unpausable default task runner during the freeze nested loop, a child dedicated worker can successfully spawn and execute JavaScript. This allows potential background script execution while the page is supposed to be frozen in the Back-Forward Cache (BFCache).
Affected files:
third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.ccthird_party/blink/renderer/core/workers/dedicated_worker.cccontent/renderer/worker/dedicated_worker_host_factory_client.cc
Estimated timestamp from git blame: 2019-05-14
Potential Root Cause Analysis
When a page is navigated away and placed into the Back-Forward Cache (BFCache), the browser freezes active execution contexts and their associated workers. However, a potential logic issue exists where DedicatedWorkerMessagingProxy::Freeze() silently drops freeze requests if the child WorkerThread has not been initialized yet, with no deferred-apply mechanism:
// third_party/blink/renderer/core/workers/dedicated_worker_messaging_proxy.cc
void DedicatedWorkerMessagingProxy::Freeze(bool is_in_back_forward_cache) {
DCHECK(IsParentContextThread());
auto* worker_thread = GetWorkerThread();
if (AskedToTerminate() || !worker_thread) // <-- Potential issue: freeze is dropped if the thread is not yet created
return;
worker_thread->Freeze(is_in_back_forward_cache);
}
Although the parent-side DedicatedWorker records requested_frozen_ = true, this flag is only evaluated during ContextLifecycleStateChanged(). It is never checked or applied by ContinueStartInternal() or StartWorkerGlobalScope() after InitializeWorkerThread() eventually creates and starts the child thread.
Enabling Primitive: Mojo Callback Dispatch on Unpausable Queue
DedicatedWorker::Start() sends a CreateWorkerHostAndStartScriptLoad IPC request to the browser. The Mojo receiver for the DedicatedWorkerHostFactoryClient is bound without specifying an explicit task runner:
// content/renderer/worker/dedicated_worker_host_factory_client.cc
factory_->CreateWorkerHostAndStartScriptLoad(
dedicated_worker_token, script_url, credentials_mode,
FetchClientSettingsObjectFromWebToMojom(fetch_client_settings_object),
std::move(blob_url_token), receiver_.BindNewPipeAndPassRemote(), // <-- Bound to default task runner
storage_access_api_status);
When bound this way, incoming Mojo messages dispatch on the default sequenced task runner. On a worker thread, this default runner corresponds to SUBTHREAD_DEFAULT_TQ.
Crucially, PauseImpl() in WorkerSchedulerImpl only voter-disables throttleable, pausable, and web-scheduling task queues; it never disables SUBTHREAD_DEFAULT_TQ. During a freeze, the thread enters a nested loop (base::RunLoop(kNestableTasksAllowed).Run()) to process outstanding events. Because SUBTHREAD_DEFAULT_TQ remains active, the Mojo reply from the browser can still be received and processed during this nested loop, allowing the nested worker to finish initializing and running completely unfrozen.
Potential Trigger Path (Nested-Worker Case)
- A page spawns a parent worker:
const parent = new Worker('parent.js'). - Inside
parent.js, a child worker is instantiated:const child = new Worker('child.js'). This posts theCreateWorkerHostAndStartScriptLoadIPC while the child’sworker_thread_on the proxy is stillnullptr. - The page navigates away, entering BFCache. The main thread signals the parent worker thread to freeze.
- The parent worker thread transitions to
PauseOrFreezeOnWorkerThread(kFrozen, true):- It iterates through observers, calling
ContextLifecycleStateChanged(kFrozen)on the childDedicatedWorkerinstance. This setsrequested_frozen_ = trueand invokescontext_proxy_->Freeze(true), which is silently dropped because the child worker thread is stillnullptr. - It enters a nested run loop via
base::RunLoop(kNestableTasksAllowed).Run()with certain task queues disabled, butSUBTHREAD_DEFAULT_TQremains enabled.
- It iterates through observers, calling
- The browser replies with
OnScriptLoadStartedon the parent worker’sSUBTHREAD_DEFAULT_TQqueue. - The handler is processed, executing
DedicatedWorker::OnScriptLoadStarted->ContinueStartInternal()->DedicatedWorkerMessagingProxy::StartWorkerGlobalScope(). - The child
DedicatedWorkerThreadis created and started. Because there is no check for the pre-existingrequested_frozen_state during startup, the child worker thread starts running its JS completely unfrozen.
Note: Our tooling agent currently does not have the ability to run code, so these are potential steps derived from static code flow analysis.
Potential Impact
An attacker page stored in the BFCache can maintain an active dedicated-worker thread executing arbitrary JavaScript for the entire duration of its cache residency. This thread can perform network fetches, issue beacons, interact with IndexedDB, and queue postMessage calls to be delivered back to the parent document upon restoration. This completely bypasses the BFCache freeze constraints designed to preserve system resources and protect user privacy.
Suggested Fix
To prevent this freeze bypass, DedicatedWorkerMessagingProxy should track the requested freeze state even before the thread starts, and apply it upon initialization.
- Add a
pending_freeze_state toDedicatedWorkerMessagingProxy(andThreadedMessagingProxyBaseif appropriate):std::optional<bool> pending_freeze_; - In
DedicatedWorkerMessagingProxy::Freeze(bool is_in_back_forward_cache):void DedicatedWorkerMessagingProxy::Freeze(bool is_in_back_forward_cache) { DCHECK(IsParentContextThread()); auto* worker_thread = GetWorkerThread(); if (!worker_thread) { pending_freeze_ = is_in_back_forward_cache; return; } ... } - During worker thread initialization inside
InitializeWorkerThread, check if a freeze is pending and apply it immediately:if (pending_freeze_.has_value()) { worker_thread_->Freeze(pending_freeze_.value()); pending_freeze_.reset(); }
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.