Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in PushAPI
DescriptionIncorrect authorization in PushAPI
ComponentPushAPI
Sub-componentPush
Bug ClassAuthorization Error
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.
Tracker499425100
Fix commit23108f0409cc (chromium/src) +176/-3
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-04 (per vendor advisory)
Fix landed2026-07-30 (public git)
Disclosed2026-09-08
Patch gap40 days — fix public in source before the advisory

Background

Fenced frame
an embedded frame that Chrome isolates from its embedder for privacy, so it is barred from privileged, joinable APIs such as Push Messaging; RenderFrameHostImpl::IsNestedWithinFencedFrame() reports whether a frame sits inside one.
`PushMessagingManager`
the browser-process (content) handler for the renderer’s Push API IPCs (Subscribe, Unsubscribe, GetSubscription), instantiated either frame-bound (with a real render_frame_id_) or worker-bound.
`ancestor_frame_type()`
a flag stored on a ServiceWorkerRegistration recording whether the registration was created inside a fenced frame (kFencedFrame) or a normal frame (kNormalFrame).
`bad_message::ReceivedBadMessage`
the browser-side mechanism that flags an IPC as unauthorized and terminates the offending renderer process, used here for the new fenced-frame rejections.

Root Cause Analysis

Pre-patch, PushMessagingManager::Subscribe() decided whether a request originated in a fenced frame by inspecting the target ServiceWorkerRegistration’s ancestor_frame_type(), and Unsubscribe() and GetSubscription() performed no fenced-frame check at all. This is the wrong signal because a renderer may pass the registration ID of any same-origin registration in the same partition, and a registration created by the embedding (normal) page reports kNormalFrame; a script running in a fenced frame could therefore name the embedder’s registration and pass the check. The invariant violated is that a fenced frame must be denied access to the Push API regardless of which registration it references.

The fix adds IsRequestFromFencedFrame(), which resolves the calling frame via RenderFrameHostImpl::FromID(render_process_host_->GetDeprecatedID(), render_frame_id_) and consults IsNestedWithinFencedFrame(), and invokes it at the top of all three IPC entry points, rejecting with a bad message before any registration lookup. It works because authorization now keys on the identity of the frame that sent the IPC rather than on a mutable, attacker-chosen target registration; the existing target-registration kFencedFrame check is retained to still cover worker-bound instances whose service worker runs inside a fenced frame.

Key insight
The single mistake was authorizing based on the target registration’s origin-frame type (which the caller can freely choose among same-origin registrations) instead of the calling frame’s actual nesting; the fix moves the decision to the caller by resolving its RenderFrameHostImpl and calling IsNestedWithinFencedFrame().

Attack Path

  1. Set up embedder registration A page registers a service worker as a normal top-level document, producing a ServiceWorkerRegistration whose ancestor_frame_type() is kNormalFrame.
  2. Load a fenced frame The page (or attacker content) embeds same-origin content inside a fenced frame, which is supposed to be denied Push API access.
  3. Send a Push IPC naming the normal registration Script in the fenced frame calls Subscribe (or Unsubscribe / GetSubscription) over the mojo pipe, passing the embedder registration’s service_worker_registration_id.
  4. Bypass the authorization check In the unpatched Subscribe(), the only fenced-frame guard reads the target registration’s ancestor_frame_type(), sees kNormalFrame, and proceeds; Unsubscribe() and GetSubscription() had no guard whatsoever, so the fenced frame reaches Push functionality it must not have.
  5. Obtain push state (inferred) The fenced frame can thereby subscribe to, cancel, or read the push subscription tied to the embedder’s registration, an authorization/privacy bypass; any further cross-context information gain is inferred and not demonstrated by the patch.

Impact Assessment

An attacker-controlled fenced frame gains unauthorized use of the Push Messaging API in the browser process, letting it subscribe, unsubscribe, or read a push subscription belonging to a same-origin normal-frame registration despite the fenced-frame isolation policy. The precondition is a same-origin ServiceWorkerRegistration created by a normal frame in the same partition whose registration ID the fenced frame supplies. The advisory classifies this as incorrect authorization in the Push API; it does not report memory corruption, code execution, or in-the-wild exploitation, and any privacy de-anonymization beyond the authorization bypass itself is (inferred).

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/push_messaging/push_messaging_manager_unittest.cc
  • tools/metrics/histograms/metadata/stability/enums.xml
  • content/browser/bad_message.h
  • content/browser/push_messaging/push_messaging_manager.cc
  • content/browser/push_messaging/push_messaging_manager.h

Audit Directions

  • Authorize on the caller, not the target
    Audit browser-process IPC handlers that infer a frame’s context from an object the renderer selected (a registration, document, or worker ID); the trustworthy signal is the calling RenderFrameHostImpl, e.g. via IsNestedWithinFencedFrame(), as BackgroundFetchServiceImpl::CreateForFrame() already does.
  • Check every entry point of a capability
    When one method of an interface gains a security guard, verify sibling methods (Unsubscribe, GetSubscription here) enforce the same restriction; missing checks on read/mutate paths are easy to overlook when only the create path is guarded.
  • Fenced-frame and partitioned-context restrictions
    Review other privacy-sensitive APIs (notifications, storage, background sync, payments) that must be denied to fenced frames, confirming they resolve the actual calling frame rather than trusting a same-partition, same-origin object supplied by the renderer.
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/tools/metrics/histograms/metadata/stability/enums.xml b/tools/metrics/histograms/metadata/stability/enums.xml
index ea6ec7ae..47d0566 100644
--- a/tools/metrics/histograms/metadata/stability/enums.xml
+++ b/tools/metrics/histograms/metadata/stability/enums.xml
@@ -542,6 +542,8 @@
   <int value="364" label="RFPH_FORM_SUBMISSION_FROM_SANDBOXED_FRAME"/>
   <int value="365" label="INSTALLED_APP_PROVIDER_FACTORY_INVALID_FRAME"/>
   <int value="366" label="RFH_INVALID_CONNECTION_ALLOWLIST_ATTRIBUTE"/>
+  <int value="367" label="PMM_UNSUBSCRIBE_IN_FENCED_FRAME"/>
+  <int value="368" label="PMM_GET_SUBSCRIPTION_IN_FENCED_FRAME"/>
 </enum>
 
 <enum name="BadMessageReasonExtensions">
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…

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