CVE-2026-87614
Overview
Background
- Fenced frame
- An embedded frame type that is strongly isolated from its embedder to protect cross-site data, and is barred from user-activation-gated navigation/window operations.
- `ServiceWorkerVersion`
- The browser-process object representing a running service worker instance; it stores
ancestor_frame_type_, the frame type of the context that registered the worker. - Window client APIs
Clients.openWindow(),WindowClient.focus(), andPaymentRequestEvent.openWindow(), script APIs that open or focus top-level windows and are gated on user activation in the renderer.- `ReportBadMessage`
- A Mojo mechanism the browser process uses to reject an IPC that violates an invariant the renderer should have enforced, terminating trust in that message.
Root Cause Analysis
The renderer gates WindowClient.focus(), Clients.openWindow(), and PaymentRequestEvent.openWindow() behind user-interaction/activation checks, and none of those gates can be satisfied from a fenced-frame service-worker context, so the calls are expected never to arrive. The browser-process handlers ServiceWorkerVersion::OpenNewTab(), ServiceWorkerVersion::OpenPaymentHandlerWindow(), and ServiceWorkerVersion::FocusClient() did not re-verify this expectation, even though ServiceWorkerVersion already knows ancestor_frame_type_. A compromised or misbehaving renderer could therefore send these IPCs for a worker whose registration ancestor was a fenced frame, and the browser would honor them, violating the invariant that fenced-frame contexts cannot invoke these window operations.
The fix adds an early check in each of the three handlers: when ancestor_frame_type_ == blink::mojom::AncestorFrameType::kFencedFrame, it calls associated_interface_receiver_.ReportBadMessage(...), resets receiver_, and returns before any window work runs. Because the guard executes first, the operation is refused and the offending renderer connection is torn down, mirroring the renderer-side restriction in the trusted process.
ancestor_frame_type_ in each handler and rejects kFencedFrame requests as bad messages.Attack Path
- Register a worker under a fenced frame
An attacker arranges for a service worker to be registered from a fenced-frame context, so its
ServiceWorkerVersioncarriesancestor_frame_type_ == kFencedFrame. - Bypass the renderer gate
Using a compromised or malicious renderer, the attacker issues an
OpenNewTab(),OpenPaymentHandlerWindow(), orFocusClient()IPC directly, skipping the renderer-side user-activation check that would normally block it. - Browser honors the request Pre-patch, the browser handler ran the window operation (open/focus) with no ancestor-frame-type check, granting the fenced-frame worker a capability it should not have.
- Isolation boundary weakened (inferred) The unauthorized window open/focus could be leveraged to undermine fenced-frame isolation guarantees, but the patch does not demonstrate a concrete data-exfiltration or navigation primitive.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/service_worker/service_worker_version.cc |
modified | |
ServiceWorkerVersionFencedFrameTestcontent/browser/service_worker/service_worker_version_unittest.cc |
modified | |
switchcontent/browser/service_worker/service_worker_version_unittest.cc |
modified | |
TEST_Pcontent/browser/service_worker/service_worker_version_unittest.cc |
modified |
Files Changed
content/browser/service_worker/service_worker_test_utils.cccontent/browser/service_worker/service_worker_version.cccontent/browser/service_worker/service_worker_test_utils.hcontent/browser/service_worker/service_worker_version_unittest.cc
Audit Directions
- Browser-side re-validation of renderer-gated invariantsAudit other service-worker and window/client IPC handlers that assume the renderer already enforced activation, origin, or frame-type constraints; the browser must independently verify anything a compromised renderer could forge.
- Fenced-frame capability gatingReview all APIs reachable from fenced-frame contexts (navigation, focus, payment, popups) to confirm each has a browser-process
AncestorFrameType/kFencedFramecheck, not just renderer gating. - Stored-context checks left unusedLook for objects that already hold a security-relevant attribute (like
ancestor_frame_type_) but fail to consult it in every handler that grants a privilege, indicating an incomplete authorization surface.
Patch
From 17630fd0572bfd5c86f1b19cc4b2dc3cdfaf2cbb Mon Sep 17 00:00:00 2001
From: Shunya Shishido <sisidovski@chromium.org>
Date: Mon, 03 Aug 2026 04:07:54 -0700
Subject: [PATCH] [ServiceWorker] Restrict window APIs in fenced-frame workers
WindowClient.focus(), Clients.openWindow() and
PaymentRequestEvent.openWindow() are gated in the renderer on user
interaction/activation gates. None of those can be triggered from a
fenced frame context for service workers.
Mirror that expectation in the browser process: ServiceWorkerVersion
already stores the registration's ancestor frame type, so check it
in FocusClient(), OpenNewTab(), and OpenPaymentHandlerWindow() and
ReportBadMessage when it is kFencedFrame.
Add ServiceWorkerVersionFencedFrameTest unit tests and plumb an
optional ancestor_frame_type through
CreateNewServiceWorkerRegistration() so the fixture can construct a
kFencedFrame registration.
TAG=agy
CONV=66793587-3f0d-4c60-ba41-165335ec8a6f
Bug: 497359396
Change-Id: I2157fafdc610b1c606fc28c52f732c2320858d40
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8173283
Reviewed-by: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
Commit-Queue: Shunya Shishido <sisidovski@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1672583}
---
diff --git a/content/browser/service_worker/service_worker_test_utils.cc b/content/browser/service_worker/service_worker_test_utils.cc
index f7ae128..f6b0bcf1 100644
--- a/content/browser/service_worker/service_worker_test_utils.cc
+++ b/content/browser/service_worker/service_worker_test_utils.cc
@@ -475,7 +475,8 @@
scoped_refptr<ServiceWorkerRegistration> CreateNewServiceWorkerRegistration(
ServiceWorkerRegistry& registry,
const blink::mojom::ServiceWorkerRegistrationOptions& options,
- const blink::StorageKey& key) {
+ const blink::StorageKey& key,
+ blink::mojom::AncestorFrameType ancestor_frame_type) {
scoped_refptr<ServiceWorkerRegistration> registration;
// Using nestable run loop because:
// * The CreateNewRegistration() internally uses a mojo remote and the
@@ -488,7 +489,7 @@
// problematic.
base::RunLoop run_loop(base::RunLoop::Type::kNestableTasksAllowed);
registry.CreateNewRegistration(
- options, key, blink::mojom::AncestorFrameType::kNormalFrame,
+ options, key, ancestor_frame_type,
base::BindLambdaForTesting(
[&](scoped_refptr<ServiceWorkerRegistration> new_registration) {
registration = std::move(new_registration);
diff --git a/content/browser/service_worker/service_worker_test_utils.h b/content/browser/service_worker/service_worker_test_utils.h
index c6d36e4..6d16ac5c 100644
--- a/content/browser/service_worker/service_worker_test_utils.h
+++ b/content/browser/service_worker/service_worker_test_utils.h
@@ -30,6 +30,7 @@
#include "services/network/public/mojom/referrer_policy.mojom-shared.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/mojom/loader/fetch_client_settings_object.mojom-forward.h"
+#include "third_party/blink/public/mojom/service_worker/service_worker_ancestor_frame_type.mojom.h"
#include "third_party/blink/public/mojom/service_worker/service_worker_provider.mojom.h"
#include "third_party/blink/public/mojom/service_worker/service_worker_registration.mojom.h"
@@ -164,7 +165,9 @@
scoped_refptr<ServiceWorkerRegistration> CreateNewServiceWorkerRegistration(
ServiceWorkerRegistry& registry,
const blink::mojom::ServiceWorkerRegistrationOptions& options,
- const blink::StorageKey& key);
+ const blink::StorageKey& key,
+ blink::mojom::AncestorFrameType ancestor_frame_type =
+ blink::mojom::AncestorFrameType::kNormalFrame);
// Calls CreateNewVersion() synchronously.
scoped_refptr<ServiceWorkerVersion> CreateNewServiceWorkerVersion(
diff --git a/content/browser/service_worker/service_worker_version.cc b/content/browser/service_worker/service_worker_version.cc
index 35ca907..965cf81 100644
--- a/content/browser/service_worker/service_worker_version.cc
+++ b/content/browser/service_worker/service_worker_version.cc
@@ -73,6 +73,7 @@
#include "third_party/blink/public/common/service_worker/service_worker_type_converters.h"
#include "third_party/blink/public/common/storage_key/storage_key.h"
#include "third_party/blink/public/mojom/service_worker/service_worker.mojom.h"
+#include "third_party/blink/public/mojom/service_worker/service_worker_ancestor_frame_type.mojom.h"
#include "third_party/perfetto/include/perfetto/tracing/track.h"
namespace content {
@@ -1866,6 +1867,12 @@
void ServiceWorkerVersion::OpenNewTab(const GURL& url,
OpenNewTabCallback callback) {
+ if (ancestor_frame_type_ == blink::mojom::AncestorFrameType::kFencedFrame) {
+ associated_interface_receiver_.ReportBadMessage(
+ "Received Clients#openWindow() request from a fenced frame.");
+ receiver_.reset();
+ return;
+ }
// TODO(crbug.com/40177656): After StorageKey implements partitioning update
// this to reject with InvalidAccessError if key_ is partitioned.
OpenWindow(url, service_worker_client_utils::WindowType::NEW_TAB_WINDOW,
@@ -1883,6 +1890,14 @@
return;
}
+ if (ancestor_frame_type_ == blink::mojom::AncestorFrameType::kFencedFrame) {
+ associated_interface_receiver_.ReportBadMessage(
+ "Received PaymentRequestEvent#openWindow() request from a fenced "
+ "frame.");
+ receiver_.reset();
+ return;
+ }
+
if (!url.is_valid() || !key_.origin().IsSameOriginWith(url)) {
associated_interface_receiver_.ReportBadMessage(
"Received PaymentRequestEvent#openWindow() request for a cross-origin "
@@ -2074,6 +2089,12 @@
std::move(callback).Run(std::move(result));
return;
}
+ if (ancestor_frame_type_ == blink::mojom::AncestorFrameType::kFencedFrame) {
+ associated_interface_receiver_.ReportBadMessage(
+ "Received WindowClient#focus() request from a fenced frame.");
+ receiver_.reset();
+ return;
+ }
ServiceWorkerClient* service_worker_client =
context_->service_worker_client_owner().GetServiceWorkerClientByClientID(
client_uuid);
diff --git a/content/browser/service_worker/service_worker_version_unittest.cc b/content/browser/service_worker/service_worker_version_unittest.cc
index b965e009..7e45b499 100644
--- a/content/browser/service_worker/service_worker_version_unittest.cc
+++ b/content/browser/service_worker/service_worker_version_unittest.cc
@@ -54,6 +54,7 @@
#include "third_party/blink/public/common/storage_key/storage_key.h"
#include "third_party/blink/public/common/tokens/tokens.h"
#include "third_party/blink/public/mojom/service_worker/service_worker.mojom.h"
+#include "third_party/blink/public/mojom/service_worker/service_worker_ancestor_frame_type.mojom.h"
#include "third_party/blink/public/mojom/service_worker/service_worker_event_status.mojom.h"
#include "third_party/blink/public/mojom/service_worker/service_worker_installed_scripts_manager.mojom.h"
#include "third_party/blink/public/mojom/service_worker/service_worker_registration.mojom.h"
@@ -150,6 +151,10 @@
protected:
using FetchHandlerExistence = blink::mojom::FetchHandlerExistence;
+ virtual blink::mojom::AncestorFrameType GetAncestorFrameType() const {
+ return blink::mojom::AncestorFrameType::kNormalFrame;
+ }
+
struct CachedMetadataUpdateListener : public ServiceWorkerVersion::Observer {
CachedMetadataUpdateListener() = default;
~CachedMetadataUpdateListener() override = default;
@@ -176,7 +181,8 @@
blink::mojom::ServiceWorkerRegistrationOptions options;
options.scope = scope_;
registration_ = CreateNewServiceWorkerRegistration(
- helper_->context()->registry(), options, GetTestStorageKey(scope_));
+ helper_->context()->registry(), options, GetTestStorageKey(scope_),
+ GetAncestorFrameType());
version_ = CreateNewServiceWorkerVersion(
helper_->context()->registry(), registration_.get(),
GURL("https://www.example.com/test/service_worker.js"),
@@ -2780,5 +2786,92 @@
bad_message_observer.WaitForBadMessage());
}
+class ServiceWorkerVersionFencedFrameTest : public ServiceWorkerVersionTest {
+ protected:
+ blink::mojom::AncestorFrameType GetAncestorFrameType() const override {
+ return blink::mojom::AncestorFrameType::kFencedFrame;
+ }
+};
+
+INSTANTIATE_TEST_SUITE_P(
+ All,
+ ServiceWorkerVersionFencedFrameTest,
+ testing::ValuesIn({StorageKeyTestCase::kFirstParty,
+ StorageKeyTestCase::kThirdParty}),
+ [](const testing::TestParamInfo<StorageKeyTestCase>& info) {
+ switch (info.param) {
+ case (StorageKeyTestCase::kFirstParty):
+ return "FirstPartyStorageKey";
+ case (StorageKeyTestCase::kThirdParty):
+ return "ThirdPartyStorageKey";
+ }
+ });
+
+// Verifies that FocusClient() rejects calls for a service worker registered
+// in a fenced frame and reports a bad message.
+TEST_P(ServiceWorkerVersionFencedFrameTest, FocusClient_Rejected) {
+ auto* service_worker =
+ helper_->AddNewPendingServiceWorker<FakeServiceWorker>(helper_.get());
+ ASSERT_EQ(blink::ServiceWorkerStatusCode::kOk,
+ StartServiceWorker(version_.get()));
+ service_worker->RunUntilInitializeGlobalScope();
+ version_->SetStatus(ServiceWorkerVersion::ACTIVATED);
+
+ mojo::test::BadMessageObserver bad_message_observer;
+
+ base::test::TestFuture<blink::mojom::FocusResultPtr> future;
+ service_worker->host()->FocusClient("some-uuid", future.GetCallback());
+
+ EXPECT_EQ("Received WindowClient#focus() request from a fenced frame.",
+ bad_message_observer.WaitForBadMessage());
+}
+
+// Verifies that OpenNewTab() rejects calls for a service worker registered
+// in a fenced frame and reports a bad message.
+TEST_P(ServiceWorkerVersionFencedFrameTest, OpenNewTab_Rejected) {
+ auto* service_worker =
+ helper_->AddNewPendingServiceWorker<FakeServiceWorker>(helper_.get());
+ ASSERT_EQ(blink::ServiceWorkerStatusCode::kOk,
+ StartServiceWorker(version_.get()));
+ service_worker->RunUntilInitializeGlobalScope();
+ version_->SetStatus(ServiceWorkerVersion::ACTIVATED);
+
+ mojo::test::BadMessageObserver bad_message_observer;
+
+ GURL url = scope_.Resolve("new_tab.html");
+ base::test::TestFuture<bool, blink::mojom::ServiceWorkerClientInfoPtr,
+ const std::optional<std::string>&>
+ future;
+
+ service_worker->host()->OpenNewTab(url, future.GetCallback());
+
+ EXPECT_EQ("Received Clients#openWindow() request from a fenced frame.",
+ bad_message_observer.WaitForBadMessage());
+}
+
+// Verifies that OpenPaymentHandlerWindow() rejects calls for a service worker
+// registered in a fenced frame and reports a bad message.
+TEST_P(ServiceWorkerVersionFencedFrameTest, OpenPaymentHandlerWindow_Rejected) {
+ auto* service_worker =
+ helper_->AddNewPendingServiceWorker<FakeServiceWorker>(helper_.get());
+ ASSERT_EQ(blink::ServiceWorkerStatusCode::kOk,
+ StartServiceWorker(version_.get()));
+ service_worker->RunUntilInitializeGlobalScope();
+ version_->SetStatus(ServiceWorkerVersion::ACTIVATED);
+
+ mojo::test::BadMessageObserver bad_message_observer;
+
+ GURL url = scope_.Resolve("payment_handler.html");
+ base::test::TestFuture<bool, blink::mojom::ServiceWorkerClientInfoPtr,
+ const std::optional<std::string>&>
+ future;
+
+ service_worker->host()->OpenPaymentHandlerWindow(url, future.GetCallback());
+
+ EXPECT_EQ(
+ "Received PaymentRequestEvent#openWindow() request from a fenced frame.",
+ bad_message_observer.WaitForBadMessage());
+}
+
} // namespace service_worker_version_unittest
} // namespace content
Regression Test / PoC
diff --git a/content/browser/service_worker/service_worker_version_unittest.cc b/content/browser/service_worker/service_worker_version_unittest.cc
index b965e009..7e45b499 100644
--- a/content/browser/service_worker/service_worker_version_unittest.cc
+++ b/content/browser/service_worker/service_worker_version_unittest.cc
@@ -54,6 +54,7 @@
#include "third_party/blink/public/common/storage_key/storage_key.h"
#include "third_party/blink/public/common/tokens/tokens.h"
#include "third_party/blink/public/mojom/service_worker/service_worker.mojom.h"
+#include "third_party/blink/public/mojom/service_worker/service_worker_ancestor_frame_type.mojom.h"
#include "third_party/blink/public/mojom/service_worker/service_worker_event_status.mojom.h"
#include "third_party/blink/public/mojom/service_worker/service_worker_installed_scripts_manager.mojom.h"
#include "third_party/blink/public/mojom/service_worker/service_worker_registration.mojom.h"
@@ -150,6 +151,10 @@
protected:
using FetchHandlerExistence = blink::mojom::FetchHandlerExistence;
+ virtual blink::mojom::AncestorFrameType GetAncestorFrameType() const {
+ return blink::mojom::AncestorFrameType::kNormalFrame;
+ }
+
struct CachedMetadataUpdateListener : public ServiceWorkerVersion::Observer {
CachedMetadataUpdateListener() = default;
~CachedMetadataUpdateListener() override = default;
@@ -176,7 +181,8 @@
blink::mojom::ServiceWorkerRegistrationOptions options;
options.scope = scope_;
registration_ = CreateNewServiceWorkerRegistration(
- helper_->context()->registry(), options, GetTestStorageKey(scope_));
+ helper_->context()->registry(), options, GetTestStorageKey(scope_),
+ GetAncestorFrameType());
version_ = CreateNewServiceWorkerVersion(
helper_->context()->registry(), registration_.get(),
GURL("https://www.example.com/test/service_worker.js"),
@@ -2780,5 +2786,92 @@
bad_message_observer.WaitForBadMessage());
}
+class ServiceWorkerVersionFencedFrameTest : public ServiceWorkerVersionTest {
+ protected:
+ blink::mojom::AncestorFrameType GetAncestorFrameType() const override {
+ return blink::mojom::AncestorFrameType::kFencedFrame;
+ }
+};
+
+INSTANTIATE_TEST_SUITE_P(
+ All,
+ ServiceWorkerVersionFencedFrameTest,
+ testing::ValuesIn({StorageKeyTestCase::kFirstParty,
+ StorageKeyTestCase::kThirdParty}),
+ [](const testing::TestParamInfo<StorageKeyTestCase>& info) {
+ switch (info.param) {
+ case (StorageKeyTestCase::kFirstParty):
+ return "FirstPartyStorageKey";
+ case (StorageKeyTestCase::kThirdParty):
+ return "ThirdPartyStorageKey";
+ }
+ });
+
+// Verifies that FocusClient() rejects calls for a service worker registered
+// in a fenced frame and reports a bad message.
+TEST_P(ServiceWorkerVersionFencedFrameTest, FocusClient_Rejected) {
+ auto* service_worker =
+ helper_->AddNewPendingServiceWorker<FakeServiceWorker>(helper_.get());
+ ASSERT_EQ(blink::ServiceWorkerStatusCode::kOk,
+ StartServiceWorker(version_.get()));
+ service_worker->RunUntilInitializeGlobalScope();
+ version_->SetStatus(ServiceWorkerVersion::ACTIVATED);
+
+ mojo::test::BadMessageObserver bad_message_observer;
+
+ base::test::TestFuture<blink::mojom::FocusResultPtr> future;
+ service_worker->host()->FocusClient("some-uuid", future.GetCallback());
+
+ EXPECT_EQ("Received WindowClient#focus() request from a fenced frame.",
+ bad_message_observer.WaitForBadMessage());
+}
+
+// Verifies that OpenNewTab() rejects calls for a service worker registered
+// in a fenced frame and reports a bad message.
+TEST_P(ServiceWorkerVersionFencedFrameTest, OpenNewTab_Rejected) {
+ auto* service_worker =
+ helper_->AddNewPendingServiceWorker<FakeServiceWorker>(helper_.get());
+ ASSERT_EQ(blink::ServiceWorkerStatusCode::kOk,
+ StartServiceWorker(version_.get()));
+ service_worker->RunUntilInitializeGlobalScope();
+ version_->SetStatus(ServiceWorkerVersion::ACTIVATED);
+
+ mojo::test::BadMessageObserver bad_message_observer;
+
+ GURL url = scope_.Resolve("new_tab.html");
+ base::test::TestFuture<bool, blink::mojom::ServiceWorkerClientInfoPtr,
+ const std::optional<std::string>&>
+ future;
+
+ service_worker->host()->OpenNewTab(url, future.GetCallback());
+
+ EXPECT_EQ("Received Clients#openWindow() request from a fenced frame.",
+ bad_message_observer.WaitForBadMessage());
+}
+
+// Verifies that OpenPaymentHandlerWindow() rejects calls for a service worker
+// registered in a fenced frame and reports a bad message.
+TEST_P(ServiceWorkerVersionFencedFrameTest, OpenPaymentHandlerWindow_Rejected) {
+ auto* service_worker =
+ helper_->AddNewPendingServiceWorker<FakeServiceWorker>(helper_.get());
+ ASSERT_EQ(blink::ServiceWorkerStatusCode::kOk,
+ StartServiceWorker(version_.get()));
+ service_worker->RunUntilInitializeGlobalScope();
+ version_->SetStatus(ServiceWorkerVersion::ACTIVATED);
+
+ mojo::test::BadMessageObserver bad_message_observer;
+
+ GURL url = scope_.Resolve("payment_handler.html");
+ base::test::TestFuture<bool, blink::mojom::ServiceWorkerClientInfoPtr,
+ const std::optional<std::string>&>
+ future;
+
+ service_worker->host()->OpenPaymentHandlerWindow(url, future.GetCallback());
+
+ EXPECT_EQ(
+ "Received PaymentRequestEvent#openWindow() request from a fenced frame.",
+ bad_message_observer.WaitForBadMessage());
+}
+
} // namespace service_worker_version_unittest
} // namespace content