Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in PushAPI
DescriptionIncorrect authorization in PushAPI
ComponentPushAPI
Bug ClassLogic Error
Tracker499425100
Fix commit23108f0409cc (chromium/src) +176/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
PushMessagingManagerFencedFrameTest
content/browser/push_messaging/push_messaging_manager_unittest.cc
modified
PushMessagingManagerFencedFrameTest
content/browser/push_messaging/push_messaging_manager_unittest.cc
modified

Files Changed

  • content/browser/bad_message.h
  • content/browser/push_messaging/push_messaging_manager.cc
  • content/browser/push_messaging/push_messaging_manager.h
  • content/browser/push_messaging/push_messaging_manager_unittest.cc
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;
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by rj...@google.com

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:

  1. Flawed Origin Check: Methods like GetSubscription, Subscribe, and Unsubscribe rely on ChildProcessSecurityPolicyImpl::CanAccessDataForOrigin. This function only validates the url::Origin and explicitly ignores the StorageKey’s partition nonce. Thus, a Fenced Frame requesting access to a same-origin embedder’s data passes this check.
  2. Flawed Fenced Frame Check: The Subscribe method (lines 192-197) attempts to block requests from Fenced Frames by checking service_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, GetSubscription and Unsubscribe lack this check entirely.
  3. 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.)

  1. An attacker embeds a mode="default" Fenced Frame that is same-origin with its top-level page.
  2. The attacker compromises the renderer process hosting the Fenced Frame.
  3. The attacker registers a dummy Service Worker inside the Fenced Frame to observe the assigned service_worker_registration_id and infers the ID of the embedder’s Service Worker.
  4. The compromised renderer directly invokes the blink::mojom::PushMessaging::GetSubscription Mojo IPC, passing the embedder’s guessed registration ID.
  5. The browser process receives the IPC. The CanAccessDataForOrigin check passes because the Fenced Frame and embedder are same-origin.
  6. 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

  1. Check Caller Context: PushMessagingManager should verify if the IPC request originates from a Fenced Frame. For frame-bound instances, check render_frame_host->IsNestedWithinFencedFrame() (or similar logic via render_frame_id_) and reject the request immediately.
  2. Validate StorageKey: When looking up or acting upon a ServiceWorkerRegistration, the browser should verify that the StorageKey of the retrieved registration strictly matches the StorageKey of 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.

View on issue tracker