CVE-2026-87652
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
PushMessagingManagerFencedFrameTestcontent/browser/push_messaging/push_messaging_manager_unittest.cc |
modified | |
PushMessagingManagerFencedFrameTestcontent/browser/push_messaging/push_messaging_manager_unittest.cc |
modified |
Files Changed
content/browser/bad_message.hcontent/browser/push_messaging/push_messaging_manager.cccontent/browser/push_messaging/push_messaging_manager.hcontent/browser/push_messaging/push_messaging_manager_unittest.cc
Patch
From 23108f0409ccaf6f2bc4297083f18cbbffa2664c Mon Sep 17 00:00:00 2001
From: Tom Sepez <tsepez@google.com>
Date: Thu, 30 Jul 2026 09:08:51 -0700
Subject: [PATCH] Push: Check calling frame for fenced-frame PushMessaging IPCs
PushMessagingManager::Subscribe() inferred whether a request came from a
fenced frame by inspecting the *target* service worker registration's
ancestor_frame_type(). That is the wrong signal: a frame can pass the
registration ID of any same-origin registration in the partition, and a
registration created by the embedding page reports kNormalFrame.
Unsubscribe() and GetSubscription() had no fenced-frame check at all.
For frame-bound instances, resolve the owning RenderFrameHost and
consult IsNestedWithinFencedFrame() at the start of each of the three
IPC entry points, mirroring the equivalent check in
BackgroundFetchServiceImpl::CreateForFrame(). The existing
target-registration check in Subscribe() is kept for worker-bound
instances.
Bug: 499425100
Change-Id: I84c9195588adcbb9721bd523133572e65424a140
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8128514
Commit-Queue: Tom Sepez <tsepez@chromium.org>
Reviewed-by: Robert Kaplow <rkaplow@chromium.org>
Reviewed-by: Dmitry Gozman <dgozman@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1671126}
---
diff --git a/content/browser/bad_message.h b/content/browser/bad_message.h
index 889e406f..8fa45ea3 100644
--- a/content/browser/bad_message.h
+++ b/content/browser/bad_message.h
@@ -392,6 +392,8 @@
RFPH_FORM_SUBMISSION_FROM_SANDBOXED_FRAME = 364,
INSTALLED_APP_PROVIDER_FACTORY_INVALID_FRAME = 365,
RFH_INVALID_CONNECTION_ALLOWLIST_ATTRIBUTE = 366,
+ PMM_UNSUBSCRIBE_IN_FENCED_FRAME = 367,
+ PMM_GET_SUBSCRIPTION_IN_FENCED_FRAME = 368,
// Please add new elements here. The naming convention is abbreviated class
// name (e.g. RenderFrameHost becomes RFH) plus a unique description of the
diff --git a/content/browser/push_messaging/push_messaging_manager.cc b/content/browser/push_messaging/push_messaging_manager.cc
index 7b4e8841..7ffaced 100644
--- a/content/browser/push_messaging/push_messaging_manager.cc
+++ b/content/browser/push_messaging/push_messaging_manager.cc
@@ -157,6 +157,15 @@
receivers_.Add(this, std::move(receiver));
}
+bool PushMessagingManager::IsRequestFromFencedFrame() const {
+ if (!IsRequestFromDocument(render_frame_id_)) {
+ return false;
+ }
+ RenderFrameHostImpl* render_frame_host = RenderFrameHostImpl::FromID(
+ render_process_host_->GetDeprecatedID(), render_frame_id_);
+ return render_frame_host && render_frame_host->IsNestedWithinFencedFrame();
+}
+
// Subscribe methods, merged in order of use.
// -----------------------------------------------------------------------------
@@ -168,6 +177,15 @@
DCHECK_CURRENTLY_ON(BrowserThread::UI);
DCHECK(options);
+ // The renderer should have checked and disallowed the request for fenced
+ // frames and thrown an exception in blink::PushManager. Ignore the request
+ // and mark it as bad if it didn't happen for some reason.
+ if (IsRequestFromFencedFrame()) {
+ bad_message::ReceivedBadMessage(&*render_process_host_,
+ bad_message::PMM_SUBSCRIBE_IN_FENCED_FRAME);
+ return;
+ }
+
RegisterData data;
data.service_worker_registration_id = service_worker_registration_id;
@@ -186,9 +204,11 @@
return;
}
- // The renderer should have checked and disallowed the request for fenced
- // frames and thrown an exception in blink::PushManager. Report a bad message
- // if the renderer if the renderer side check didn't happen for some reason.
+ // Registrations created on behalf of a fenced frame may not have push
+ // subscriptions. Together with the IsRequestFromFencedFrame() check above
+ // this also catches requests from service workers running in such a frame
+ // (which use a worker-bound instance of this class) when they target their
+ // own registration.
if (service_worker_registration->ancestor_frame_type() ==
blink::mojom::AncestorFrameType::kFencedFrame) {
bad_message::ReceivedBadMessage(render_process_host_->GetDeprecatedID(),
@@ -505,6 +525,13 @@
void PushMessagingManager::Unsubscribe(int64_t service_worker_registration_id,
UnsubscribeCallback callback) {
DCHECK_CURRENTLY_ON(BrowserThread::UI);
+
+ if (IsRequestFromFencedFrame()) {
+ bad_message::ReceivedBadMessage(
+ &*render_process_host_, bad_message::PMM_UNSUBSCRIBE_IN_FENCED_FRAME);
+ return;
+ }
+
scoped_refptr<ServiceWorkerRegistration> service_worker_registration =
service_worker_context_->GetLiveRegistration(
service_worker_registration_id);
@@ -601,6 +628,13 @@
GetSubscriptionCallback callback) {
DCHECK_CURRENTLY_ON(BrowserThread::UI);
+ if (IsRequestFromFencedFrame()) {
+ bad_message::ReceivedBadMessage(
+ &*render_process_host_,
+ bad_message::PMM_GET_SUBSCRIPTION_IN_FENCED_FRAME);
+ return;
+ }
+
scoped_refptr<ServiceWorkerRegistration> registration =
service_worker_context_->GetLiveRegistration(
service_worker_registration_id);
diff --git a/content/browser/push_messaging/push_messaging_manager.h b/content/browser/push_messaging/push_messaging_manager.h
index 5d0b145..34ee0a6 100644
--- a/content/browser/push_messaging/push_messaging_manager.h
+++ b/content/browser/push_messaging/push_messaging_manager.h
@@ -172,6 +172,10 @@
const std::vector<std::string>& push_subscription_id_and_sender_info,
blink::ServiceWorkerStatusCode service_worker_status);
+ // Returns whether this is a frame-bound instance whose owning frame is nested
+ // within a fenced frame.
+ bool IsRequestFromFencedFrame() const;
+
PushMessagingService* GetService();
const raw_ref<RenderProcessHost> render_process_host_;
diff --git a/content/browser/push_messaging/push_messaging_manager_unittest.cc b/content/browser/push_messaging/push_messaging_manager_unittest.cc
index 8f8d10e4..51d6e8b 100644
--- a/content/browser/push_messaging/push_messaging_manager_unittest.cc
+++ b/content/browser/push_messaging/push_messaging_manager_unittest.cc
@@ -12,6 +12,7 @@
#include "base/functional/callback_helpers.h"
#include "base/memory/scoped_refptr.h"
#include "base/test/metrics/histogram_tester.h"
+#include "base/test/scoped_feature_list.h"
#include "content/browser/bad_message.h"
#include "content/browser/origin_agent_cluster_isolation_state.h"
#include "content/browser/security/cpsp/child_process_security_policy_impl.h"
@@ -19,11 +20,15 @@
#include "content/browser/service_worker/service_worker_context_wrapper.h"
#include "content/browser/service_worker/service_worker_registration.h"
#include "content/public/browser/browser_context.h"
+#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/storage_partition.h"
#include "content/public/test/browser_task_environment.h"
#include "content/public/test/mock_render_process_host.h"
#include "content/public/test/test_browser_context.h"
+#include "content/public/test/test_renderer_host.h"
+#include "content/test/test_render_view_host.h"
#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/common/storage_key/storage_key.h"
#include "third_party/blink/public/mojom/push_messaging/push_messaging.mojom.h"
#include "third_party/blink/public/mojom/service_worker/service_worker_registration.mojom.h"
@@ -164,4 +169,130 @@
bad_message::PMM_GET_SUBSCRIPTION_INVALID_ORIGIN, 1);
}
+class PushMessagingManagerFencedFrameTest
+ : public RenderViewHostImplTestHarness {
+ public:
+ PushMessagingManagerFencedFrameTest() {
+ scoped_feature_list_.InitAndEnableFeatureWithParameters(
+ blink::features::kFencedFrames, {{"implementation_type", "mparch"}});
+ }
+ ~PushMessagingManagerFencedFrameTest() override = default;
+
+ void SetUp() override {
+ RenderViewHostImplTestHarness::SetUp();
+ NavigateAndCommit(GURL("https://example.com/"));
+
+ fenced_frame_rfh_ =
+ RenderFrameHostTester::For(main_rfh())->AppendFencedFrame();
+ ASSERT_TRUE(fenced_frame_rfh_);
+ ASSERT_TRUE(fenced_frame_rfh_->IsNestedWithinFencedFrame());
+
+ sw_context_ =
+ base::WrapRefCounted(static_cast<ServiceWorkerContextWrapper*>(
+ fenced_frame_rfh_->GetStoragePartition()
+ ->GetServiceWorkerContext()));
+
+ push_manager_ = std::make_unique<PushMessagingManager>(
+ *fenced_frame_rfh_->GetProcess(), fenced_frame_rfh_->GetRoutingID(),
+ sw_context_);
+ }
+
+ void TearDown() override {
+ push_manager_.reset();
+ sw_context_.reset();
+ fenced_frame_rfh_ = nullptr;
Regression Test / PoC
diff --git a/content/browser/push_messaging/push_messaging_manager_unittest.cc b/content/browser/push_messaging/push_messaging_manager_unittest.cc
index 8f8d10e4..51d6e8b 100644
--- a/content/browser/push_messaging/push_messaging_manager_unittest.cc
+++ b/content/browser/push_messaging/push_messaging_manager_unittest.cc
@@ -12,6 +12,7 @@
#include "base/functional/callback_helpers.h"
#include "base/memory/scoped_refptr.h"
#include "base/test/metrics/histogram_tester.h"
+#include "base/test/scoped_feature_list.h"
#include "content/browser/bad_message.h"
#include "content/browser/origin_agent_cluster_isolation_state.h"
#include "content/browser/security/cpsp/child_process_security_policy_impl.h"
@@ -19,11 +20,15 @@
#include "content/browser/service_worker/service_worker_context_wrapper.h"
#include "content/browser/service_worker/service_worker_registration.h"
#include "content/public/browser/browser_context.h"
+#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/storage_partition.h"
#include "content/public/test/browser_task_environment.h"
#include "content/public/test/mock_render_process_host.h"
#include "content/public/test/test_browser_context.h"
+#include "content/public/test/test_renderer_host.h"
+#include "content/test/test_render_view_host.h"
#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/common/storage_key/storage_key.h"
#include "third_party/blink/public/mojom/push_messaging/push_messaging.mojom.h"
#include "third_party/blink/public/mojom/service_worker/service_worker_registration.mojom.h"
@@ -164,4 +169,130 @@
bad_message::PMM_GET_SUBSCRIPTION_INVALID_ORIGIN, 1);
}
+class PushMessagingManagerFencedFrameTest
+ : public RenderViewHostImplTestHarness {
+ public:
+ PushMessagingManagerFencedFrameTest() {
+ scoped_feature_list_.InitAndEnableFeatureWithParameters(
+ blink::features::kFencedFrames, {{"implementation_type", "mparch"}});
+ }
+ ~PushMessagingManagerFencedFrameTest() override = default;
+
+ void SetUp() override {
+ RenderViewHostImplTestHarness::SetUp();
+ NavigateAndCommit(GURL("https://example.com/"));
+
+ fenced_frame_rfh_ =
+ RenderFrameHostTester::For(main_rfh())->AppendFencedFrame();
+ ASSERT_TRUE(fenced_frame_rfh_);
+ ASSERT_TRUE(fenced_frame_rfh_->IsNestedWithinFencedFrame());
+
+ sw_context_ =
+ base::WrapRefCounted(static_cast<ServiceWorkerContextWrapper*>(
+ fenced_frame_rfh_->GetStoragePartition()
+ ->GetServiceWorkerContext()));
+
+ push_manager_ = std::make_unique<PushMessagingManager>(
+ *fenced_frame_rfh_->GetProcess(), fenced_frame_rfh_->GetRoutingID(),
+ sw_context_);
+ }
+
+ void TearDown() override {
+ push_manager_.reset();
+ sw_context_.reset();
+ fenced_frame_rfh_ = nullptr;
+ RenderViewHostImplTestHarness::TearDown();
+ }
+
+ protected:
+ // Creates a live service worker registration that was registered by a normal
+ // (non-fenced) frame, simulating a registration belonging to the embedder.
+ scoped_refptr<ServiceWorkerRegistration> CreateNormalFrameRegistration(
+ int64_t registration_id) {
+ auto sw_options = blink::mojom::ServiceWorkerRegistrationOptions::New();
+ sw_options->scope = GURL("https://example.com/");
+ return ServiceWorkerRegistration::Create(
+ *sw_options,
+ blink::StorageKey::CreateFromStringForTesting("https://example.com/"),
+ registration_id, sw_context_->context()->AsWeakPtr(),
+ blink::mojom::AncestorFrameType::kNormalFrame);
+ }
+
+ raw_ptr<RenderFrameHost> fenced_frame_rfh_ = nullptr;
+ scoped_refptr<ServiceWorkerContextWrapper> sw_context_;
+ std::unique_ptr<PushMessagingManager> push_manager_;
+
+ private:
+ base::test::ScopedFeatureList scoped_feature_list_;
+};
+
+// Verifies that a frame nested within a fenced frame may not subscribe to push
+// notifications via a service worker registration that belongs to a normal
+// (non-fenced) frame.
+TEST_F(PushMessagingManagerFencedFrameTest,
+ SubscribeRejectedForNormalFrameRegistration) {
+ const int64_t kServiceWorkerRegistrationId = 42L;
+ scoped_refptr<ServiceWorkerRegistration> registration =
+ CreateNormalFrameRegistration(kServiceWorkerRegistrationId);
+
+ base::HistogramTester histogram_tester;
+ push_manager_->Subscribe(
+ kServiceWorkerRegistrationId,
+ blink::mojom::PushSubscriptionOptions::New(),
+ /*user_gesture=*/false,
+ base::BindOnce([](blink::mojom::PushRegistrationStatus status,
+ blink::mojom::PushSubscriptionPtr subscription) {
+ FAIL() << "Callback should not be called when bad message is detected.";
+ }));
+
+ histogram_tester.ExpectUniqueSample(
+ "Stability.BadMessageTerminated.Content",
+ bad_message::PMM_SUBSCRIBE_IN_FENCED_FRAME, 1);
+}
+
+// Verifies that a frame nested within a fenced frame may not unsubscribe a
+// service worker registration that belongs to a normal (non-fenced) frame from
+// push notifications.
+TEST_F(PushMessagingManagerFencedFrameTest,
+ UnsubscribeRejectedForNormalFrameRegistration) {
+ const int64_t kServiceWorkerRegistrationId = 42L;
+ scoped_refptr<ServiceWorkerRegistration> registration =
+ CreateNormalFrameRegistration(kServiceWorkerRegistrationId);
+
+ base::HistogramTester histogram_tester;
+ push_manager_->Unsubscribe(
+ kServiceWorkerRegistrationId,
+ base::BindOnce([](blink::mojom::PushErrorType error_type,
+ bool did_unsubscribe,
+ const std::optional<std::string>& error_message) {
+ FAIL() << "Callback should not be called when bad message is detected.";
+ }));
+
+ histogram_tester.ExpectUniqueSample(
+ "Stability.BadMessageTerminated.Content",
+ bad_message::PMM_UNSUBSCRIBE_IN_FENCED_FRAME, 1);
+}
+
+// Verifies that a frame nested within a fenced frame may not retrieve the push
+// subscription of a service worker registration that belongs to a normal
+// (non-fenced) frame.
+TEST_F(PushMessagingManagerFencedFrameTest,
+ GetSubscriptionRejectedForNormalFrameRegistration) {
+ const int64_t kServiceWorkerRegistrationId = 42L;
+ scoped_refptr<ServiceWorkerRegistration> registration =
+ CreateNormalFrameRegistration(kServiceWorkerRegistrationId);
+
+ base::HistogramTester histogram_tester;
+ push_manager_->GetSubscription(
+ kServiceWorkerRegistrationId,
+ base::BindOnce([](blink::mojom::PushGetRegistrationStatus status,
+ blink::mojom::PushSubscriptionPtr subscription) {
+ FAIL() << "Callback should not be called when bad message is detected.";
+ }));
+
+ histogram_tester.ExpectUniqueSample(
+ "Stability.BadMessageTerminated.Content",
+ bad_message::PMM_GET_SUBSCRIPTION_IN_FENCED_FRAME, 1);
+}
+
} // namespace content
Original Bug Report
Bypass of Fenced Frame isolation in PushMessagingManager via Mojo IPC
Flapjack, 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 without the security team.
Overview: The PushMessagingManager fails to enforce Fenced Frame storage partitioning when handling Mojo IPC requests. By providing a guessed Service Worker registration ID, a compromised renderer in a same-origin Fenced Frame can bypass origin checks to access or modify the push subscriptions of its embedder. This potentially breaks the intended privacy boundary and allows cross-context tracking.
Affected files:
content/browser/push_messaging/push_messaging_manager.cc
Estimated timestamp from git blame: 2024-12-10
Summary
There is a potential vulnerability in content/browser/push_messaging/push_messaging_manager.cc where a compromised renderer in a Fenced Frame can bypass storage isolation. By invoking blink::mojom::PushMessaging IPC methods with the Service Worker registration ID of its embedder, the Fenced Frame can read, create, or delete the embedder’s push subscriptions.
Vulnerability Details
Fenced Frames are designed to be strictly isolated from their embedder via a partitioned StorageKey that includes a unique nonce, even if they share the same url::Origin (e.g., in mode="default"). However, the browser-side checks in PushMessagingManager fail to properly enforce this boundary:
- Flawed Origin Check: Methods like
GetSubscription,Subscribe, andUnsubscriberely onChildProcessSecurityPolicyImpl::CanAccessDataForOrigin. This function only validates theurl::Originand explicitly ignores theStorageKey’s partition nonce. Thus, a Fenced Frame requesting access to a same-origin embedder’s data passes this check. - Flawed Fenced Frame Check: The
Subscribemethod (lines 192-197) attempts to block requests from Fenced Frames by checkingservice_worker_registration->ancestor_frame_type() == kFencedFrame. However, this checks the target registration, not the calling context. If the Fenced Frame targets a registration created by the normal unpartitioned embedder, this check passes. Furthermore,GetSubscriptionandUnsubscribelack this check entirely. - Guessable IDs: Service Worker registration IDs are sequentially generated 64-bit integers global to the
StoragePartition. An attacker can easily guess the embedder’s ID by registering a dummy Service Worker to leak the current sequence counter.
Potential Steps to Trigger
(Note: These are suggested steps based on code analysis; our tooling agent does not yet have the ability to run code or provide a live working proof of concept.)
- An attacker embeds a
mode="default"Fenced Frame that is same-origin with its top-level page. - The attacker compromises the renderer process hosting the Fenced Frame.
- The attacker registers a dummy Service Worker inside the Fenced Frame to observe the assigned
service_worker_registration_idand infers the ID of the embedder’s Service Worker. - The compromised renderer directly invokes the
blink::mojom::PushMessaging::GetSubscriptionMojo IPC, passing the embedder’s guessed registration ID. - The browser process receives the IPC. The
CanAccessDataForOrigincheck passes because the Fenced Frame and embedder are same-origin. - The browser retrieves the embedder’s full, sensitive Push Subscription details (Endpoint URL, P256DH key, Auth secret) and returns them to the attacker via the Mojo callback, violating Fenced Frame isolation.
Suggested Fix
- Check Caller Context:
PushMessagingManagershould verify if the IPC request originates from a Fenced Frame. For frame-bound instances, checkrender_frame_host->IsNestedWithinFencedFrame()(or similar logic viarender_frame_id_) and reject the request immediately. - Validate StorageKey: When looking up or acting upon a
ServiceWorkerRegistration, the browser should verify that theStorageKeyof the retrieved registration strictly matches theStorageKeyof the calling context, including the partition nonce.
Evaluated with Chrome root at commit: 09ec9e7cc4d24823d20b6d37cf3d282734f6bf0f
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.