Low chrome Authorization Error 🔧 Commit mapped

Overview

Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in WebAppInstalls
DescriptionIncorrect authorization in WebAppInstalls
ComponentWebAppInstalls
Sub-componentPWA
Bug ClassAuthorization Error
Tracker553269860
Fix commitaa50be414aa2 (chromium/src) +167/-37
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
ServiceWorkerBindingContext
chrome/browser/badging/badge_manager.h
modified
for
chrome/browser/ui/web_applications/web_app_badging_browsertest.cc
modified
if
chrome/browser/ui/web_applications/web_app_badging_browsertest.cc
modified

Files Changed

  • chrome/browser/ui/web_applications/web_app_badging_browsertest.cc
  • chrome/test/data/web_app_badging/badging_with_frames_and_workers.html
  • chrome/browser/badging/badge_manager.cc
  • chrome/browser/badging/badge_manager.h
From aa50be414aa20c2ef4fed3aced092af3628659e6 Mon Sep 17 00:00:00 2001
From: Dibyajyoti Pal <dibyapal@google.com>
Date: Tue, 08 Sep 2026 17:58:17 -0700
Subject: [PATCH] [PWA] Prevent cross-partition Badging API manipulation

The Badging API browser backend (`badging::BadgeManager`) previously
authorized `setAppBadge` and `clearAppBadge` requests based purely on
URL and service worker scope matching against installed PWAs, without
verifying whether the caller was in a third-party partitioned context.
Consequently, embedded cross-site iframes and partitioned service
workers could mutate or clear the OS-level badge of an installed
first-party PWA.

This change:
1. Rejects binding `blink.mojom.BadgeService` in
   `BadgeManager::BindFrameReceiverIfAllowed` when
   `frame->GetStorageKey().IsThirdPartyContext()` is true.
2. Rejects binding `blink.mojom.BadgeService` in
   `BadgeManager::BindServiceWorkerReceiverIfAllowed` when
   `info.storage_key.IsThirdPartyContext()` is true.
3. Adds defense-in-depth checks in both `FrameBindingContext` and
   `ServiceWorkerBindingContext` `GetAppIdsAndUrlsForBadging()`.
4. Updates `WebAppBadgingBrowserTest` to verify that cross-site
   partitioned frames and service workers cannot alter installed PWA
   badges.

TAG=agy
CONV=3c5bef9b-629a-426d-8def-9825d894854c

Fixed: 553269860, 555293654
Change-Id: I5d5c2e39fccd18263c1c743bd5075705326376f0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8334309
Reviewed-by: Kunjan Patel <ksukh@chromium.org>
Reviewed-by: Mike Wasserman <msw@chromium.org>
Commit-Queue: Dibyajyoti Pal <dibyapal@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1694247}
---
diff --git a/chrome/browser/badging/badge_manager.cc b/chrome/browser/badging/badge_manager.cc
index 012137be..550015b9 100644
--- a/chrome/browser/badging/badge_manager.cc
+++ b/chrome/browser/badging/badge_manager.cc
@@ -89,6 +89,11 @@
     return;
   }
 
+  // The Badging API is only allowed in first-party contexts.
+  if (frame->GetStorageKey().IsThirdPartyContext()) {
+    return;
+  }
+
   auto* profile = Profile::FromBrowserContext(frame->GetBrowserContext());
 
   auto* badge_manager =
@@ -115,6 +120,11 @@
     return;
   }
 
+  // The Badging API is only allowed in first-party contexts.
+  if (info.storage_key.IsThirdPartyContext()) {
+    return;
+  }
+
   auto* profile = Profile::FromBrowserContext(
       service_worker_process_host->GetBrowserContext());
 
@@ -124,7 +134,8 @@
     return;
 
   auto context = std::make_unique<BadgeManager::ServiceWorkerBindingContext>(
-      service_worker_process_host->GetDeprecatedID(), info.scope);
+      service_worker_process_host->GetDeprecatedID(), info.scope,
+      info.storage_key);
 
   badge_manager->receivers_.Add(badge_manager, std::move(receiver),
                                 std::move(context));
@@ -175,6 +186,10 @@
   return previous;
 }
 
+void BadgeManager::FlushReceiversForTesting() {
+  receivers_.FlushForTesting();
+}
+
 void BadgeManager::UpdateBadge(const webapps::AppId& app_id,
                                std::optional<BadgeValue> value) {
   web_app::WebAppSyncBridge* sync_bridge =
@@ -257,8 +272,9 @@
   DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
   content::RenderFrameHost* frame =
       content::RenderFrameHost::FromID(process_id_, frame_id_);
-  if (!frame)
+  if (!frame || frame->GetStorageKey().IsThirdPartyContext()) {
     return std::vector<std::tuple<webapps::AppId, GURL>>{};
+  }
 
   const WebAppProvider* provider = WebAppProvider::GetForLocalAppsUnchecked(
       Profile::FromBrowserContext(frame->GetBrowserContext()));
@@ -280,6 +296,10 @@
 BadgeManager::ServiceWorkerBindingContext::GetAppIdsAndUrlsForBadging() const {
   DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
 
+  if (storage_key_.IsThirdPartyContext()) {
+    return std::vector<std::tuple<webapps::AppId, GURL>>{};
+  }
+
   content::RenderProcessHost* render_process_host =
       content::RenderProcessHost::FromID(process_id_);
   if (!render_process_host)
diff --git a/chrome/browser/badging/badge_manager.h b/chrome/browser/badging/badge_manager.h
index 95d2cb2c..b304c81 100644
--- a/chrome/browser/badging/badge_manager.h
+++ b/chrome/browser/badging/badge_manager.h
@@ -17,6 +17,7 @@
 #include "components/webapps/common/web_app_id.h"
 #include "content/public/browser/service_worker_version_base_info.h"
 #include "mojo/public/cpp/bindings/receiver_set.h"
+#include "third_party/blink/public/common/storage_key/storage_key.h"
 #include "third_party/blink/public/mojom/badging/badging.mojom.h"
 #include "third_party/blink/public/mojom/service_worker/service_worker_ancestor_frame_type.mojom.h"
 #include "url/gurl.h"
@@ -106,6 +107,7 @@
   void ClearBadgeForTesting(const webapps::AppId& app_id,
                             ukm::UkmRecorder* test_recorder);
   const base::Clock* SetClockForTesting(const base::Clock* clock);
+  void FlushReceiversForTesting();
 
  private:
   // The BindingContext of a mojo request. Allows mojo calls to be tied back
@@ -143,8 +145,10 @@
   // The BindingContext for ServiceWorkerGlobalScope execution contexts.
   class ServiceWorkerBindingContext final : public BindingContext {
    public:
-    ServiceWorkerBindingContext(int process_id, const GURL& scope)
-        : process_id_(process_id), scope_(scope) {}
+    ServiceWorkerBindingContext(int process_id,
+                                const GURL& scope,
+                                const blink::StorageKey& storage_key)
+        : process_id_(process_id), scope_(scope), storage_key_(storage_key) {}
     ~ServiceWorkerBindingContext() override = default;
 
     // Returns the list of AppIds within the service worker's scope. Returns
@@ -155,6 +159,7 @@
    private:
     int process_id_;
     GURL scope_;
+    blink::StorageKey storage_key_;
   };
 
   // Updates the badge for |app_id| to be |value|, if it is not std::nullopt.
diff --git a/chrome/browser/ui/web_applications/web_app_badging_browsertest.cc b/chrome/browser/ui/web_applications/web_app_badging_browsertest.cc
index c782226..7e9abb5 100644
--- a/chrome/browser/ui/web_applications/web_app_badging_browsertest.cc
+++ b/chrome/browser/ui/web_applications/web_app_badging_browsertest.cc
@@ -25,6 +25,8 @@
 #include "content/public/browser/web_contents.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
+#include "net/dns/mock_host_resolver.h"
+#include "services/network/public/cpp/network_switches.h"
 
 using content::RenderFrameHost;
 
@@ -35,20 +37,30 @@
   WebAppBadgingBrowserTest()
       : cross_origin_https_server_(net::EmbeddedTestServer::TYPE_HTTPS) {}
 
+  // Host resolver rules must be set via command-line switches so out-of-process
+  // Network Services (e.g., on ChromeOS and Windows) receive the DNS mapping at
+  // startup. This allows early navigations in SetUpOnMainThread() to resolve
+  // cross-origin test hosts before Mojo IPC synchronization occurs.
+  void SetUpCommandLine(base::CommandLine* command_line) override {
+    WebAppBrowserTestBase::SetUpCommandLine(command_line);
+    command_line->AppendSwitchASCII(network::switches::kHostResolverRules,
+                                    "MAP * 127.0.0.1");
+  }
+
   void SetUpOnMainThread() override {
     WebAppBrowserTestBase::SetUpOnMainThread();
 
+    cross_origin_https_server_.SetSSLConfig(
+        net::EmbeddedTestServer::CERT_TEST_NAMES);
+    cross_origin_https_server_.AddDefaultHandlers(GetChromeTestDataDir());
     ASSERT_TRUE(cross_origin_https_server_.Start());
-    ASSERT_TRUE(embedded_test_server()->Start());
 
-    GURL cross_site_frame_url =
-        cross_origin_https_server_.GetURL("/web_app_badging/blank.html");
+    GURL cross_site_frame_url = cross_origin_https_server_.GetURL(
+        "b.test", "/web_app_badging/blank.html");
     cross_site_app_id_ = InstallPWA(cross_site_frame_url);
 
-    // Note: The url for the cross site frame is embedded in the query string.
     GURL start_url = embedded_https_test_server().GetURL(
-        "/web_app_badging/badging_with_frames_and_workers.html?url=" +
-        cross_site_frame_url.spec());
+        "/web_app_badging/badging_with_frames_and_workers.html");
     main_app_id_ = InstallPWA(start_url);
 
     GURL sub_start_url =
@@ -64,6 +76,10 @@
     apps::AppReadinessWaiter(profile(), sub_app_id_).Await();
 
     content::WebContents* web_contents = OpenApplication(main_app_id_);
+    ASSERT_TRUE(content::WaitForLoadStop(web_contents));
+    ASSERT_TRUE(content::NavigateIframeToURL(web_contents, "cross-site",
+                                             cross_site_frame_url));
+
     // There should be exactly 4 frames:
     // 1) The main frame.
     // 2) A frame containing a sub app.
@@ -71,7 +87,6 @@
     // 4) A sub frame in the app's scope.
     auto frames = CollectAllRenderFrameHosts(web_contents->GetPrimaryPage());
     ASSERT_EQ(4u, frames.size());
-
     main_frame_ = web_contents->GetPrimaryMainFrame();
     for (auto* frame : frames) {
       if (frame->GetLastCommittedURL() == sub_start_url) {
@@ -83,17 +98,17 @@
         cross_site_frame_ = frame;
       }
     }
-
     ASSERT_TRUE(main_frame_);
     ASSERT_TRUE(sub_app_frame_);
     ASSERT_TRUE(in_scope_frame_);
     ASSERT_TRUE(cross_site_frame_);
+    ASSERT_EQ(cross_site_frame_url, cross_site_frame_->GetLastCommittedURL());
 
-    // Register two service workers:
+    // Register two service workers on a.test:
     // 1) A service worker with a scope that applies to both the main app and
     // the sub app.
     // 2) A service worker with a scope that applies to the sub app only.
-    app_service_worker_scope_ = start_url.GetWithoutFilename();
+    // app_service_worker_scope_ = start_url.GetWithoutFilename();
     const std::string register_app_service_worker_script = content::JsReplace(
         kRegisterServiceWorkerScript, app_service_worker_scope_.spec());
     ASSERT_EQ("OK",
@@ -138,7 +153,7 @@
 
   void OnBadgeChanged() {
     // This is only set up to deal with one badge change at a time per app,
-    // in order to make asserting the result of a badge change easier.  A single
+    // in order to make asserting the result of a badge change easier. A single
     // service worker badge call may affect multiple apps within its scope.
     const size_t total_changes =
         delegate_->cleared_badges().size() + delegate_->set_badges().size();
@@ -238,6 +253,12 @@
         main_frame_, expected_badge_change_count);
   }
 
+  badging::TestBadgeManagerDelegate* delegate() { return delegate_; }
+
+  badging::BadgeManager* badge_manager() {
+    return badging::BadgeManagerFactory::GetForProfile(profile());
+  }
+
   const webapps::AppId& main_app_id() { return main_app_id_; }
   const webapps::AppId& sub_app_id() { return sub_app_id_; }
   const webapps::AppId& cross_site_app_id() { return cross_site_app_id_; }
@@ -261,7 +282,7 @@
   // Only the sub app is within this scope.
   GURL sub_app_service_worker_scope_;
 
-  // Frame badge updates affect the badge for at most 1 app.  However, a single
+  // Frame badge updates affect the badge for at most 1 app. However, a single
   // service worker badge update may affect multiple apps.
   size_t expected_badge_change_count_ = 0;
 
@@ -297,26 +318,115 @@
   net::EmbeddedTestServer cross_origin_https_server_;
 };
 
-// Tests that the badge for the main frame is not affected by changing the badge
-// of a cross site subframe.
+// Tests that a cross site subframe (in a third-party partitioned context)
+// cannot change the badge of the main frame or the cross site app.
 IN_PROC_BROWSER_TEST_F(WebAppBadgingBrowserTest,
-                       CrossSiteFrameCannotChangeMainFrameBadge) {
-  // Clearing from cross site frame should affect only the cross site app.
-  ExecuteScriptAndWaitForBadgeChange("navigator.clearAppBadge()",
-                                     cross_site_frame_);
+                       CrossSiteFrameCannotChangeBadge) {
+  // Set a baseline badge on the app from the first-party main frame.
+  ExecuteScriptAndWaitForBadgeChange("navigator.setAppBadge(99)", main_frame_);
   BadgeChange badge_change;
-  ASSERT_NO_FATAL_FAILURE(GetBadgeChange(cross_site_app_id(), &badge_change));
-  ASSERT_TRUE(badge_change.was_cleared_);
-  ASSERT_FALSE(badge_change.was_flagged_);
+  ASSERT_NO_FATAL_FAILURE(GetBadgeChange(main_app_id(), &badge_change));
+  EXPECT_EQ(99u, badge_change.last_badge_content_);
+  delegate()->ResetBadges();
 
-  // Setting from cross site frame should affect only the cross site app.
-  ExecuteScriptAndWaitForBadgeChange("navigator.setAppBadge(77)",
-                                     cross_site_frame_);
+  // Clearing from cross site frame should not clear or affect any badges.
+  ASSERT_TRUE(content::ExecJs(cross_site_frame_, "navigator.clearAppBadge()"));
+  badge_manager()->FlushReceiversForTesting();
+  EXPECT_TRUE(delegate()->cleared_badges().empty());
+  EXPECT_TRUE(delegate()->set_badges().empty());
+  EXPECT_EQ(99u, badge_manager()->GetBadgeValue(main_app_id()));
 
-  ASSERT_NO_FATAL_FAILURE(GetBadgeChange(cross_site_app_id(), &badge_change));
-  ASSERT_FALSE(badge_change.was_cleared_);
-  ASSERT_FALSE(badge_change.was_flagged_);
-  ASSERT_EQ(77u, badge_change.last_badge_content_);
+  // Setting from cross site frame should not overwrite or affect any badges.
+  ASSERT_TRUE(content::ExecJs(cross_site_frame_, "navigator.setAppBadge(77)"));
+  badge_manager()->FlushReceiversForTesting();
+  EXPECT_TRUE(delegate()->cleared_badges().empty());
+  EXPECT_TRUE(delegate()->set_badges().empty());
+  EXPECT_EQ(99u, badge_manager()->GetBadgeValue(main_app_id()));
+}
+
+// Tests that a service worker in a cross-site subframe (in a third-party
+// partitioned context) cannot change or clear badges for any app.
+IN_PROC_BROWSER_TEST_F(WebAppBadgingBrowserTest,
+                       CrossSiteServiceWorkerCannotChangeBadge) {
+  // Register a service worker inside the cross-site (b.test) subframe.
+  GURL cross_site_sw_scope =
+      cross_site_frame_->GetLastCommittedURL().GetWithoutFilename();
+  const std::string register_script = content::JsReplace(
+      R"(
+        (async () => {
+          const reg = await navigator.serviceWorker.register(
+              '/web_app_badging/service_worker.js', {scope: $1});
+          if (reg.active) {
+            return 'OK';
+          }
+          const worker = reg.waiting || reg.installing;
+          if (!worker) {
+            return 'NO_WORKER';
+          }
+          if (worker.state === 'activated') {
+            return 'OK';
+          }
+          return new Promise((resolve, reject) => {
+            worker.addEventListener('statechange', () => {
+              if (worker.state === 'activated') resolve('OK');
+              if (worker.state === 'redundant') reject('worker became redundant');
+            });
+          });
+        })();
+      )",
+      cross_site_sw_scope.spec());
+  ASSERT_EQ("OK", EvalJs(cross_site_frame_, register_script));
+
+  // Set a baseline badge on the app from the first-party main frame.
+  ExecuteScriptAndWaitForBadgeChange("navigator.setAppBadge(99)", main_frame_);
+  BadgeChange badge_change;
+  ASSERT_NO_FATAL_FAILURE(GetBadgeChange(main_app_id(), &badge_change));
+  EXPECT_EQ(99u, badge_change.last_badge_content_);
+  delegate()->ResetBadges();
+
+  // Instruct the partitioned service worker to clear a badge.
+  const std::string clear_badge_script = content::JsReplace(
+      R"(
+        (async () => {
+          const reg = await navigator.serviceWorker.getRegistration($1);
+          const worker = reg ? (reg.active || reg.waiting || reg.installing) : null;
+          if (!worker) {
+            return 'NO_WORKER';
+          }
+          return new Promise((resolve) => {
+            navigator.serviceWorker.addEventListener('message', (e) => resolve(e.data), {once: true});
+            worker.postMessage({ command: 'clear-app-badge' });
+          });
+        })();
+      )",
+      cross_site_sw_scope.spec());
+  ASSERT_EQ("OK", EvalJs(cross_site_frame_, clear_badge_script));
+  badge_manager()->FlushReceiversForTesting();
+  EXPECT_TRUE(delegate()->cleared_badges().empty());
+  EXPECT_TRUE(delegate()->set_badges().empty());
+  EXPECT_EQ(99u, badge_manager()->GetBadgeValue(main_app_id()));
+
+  // Instruct the partitioned service worker to set a badge.
+  const std::string set_badge_script = content::JsReplace(
+      R"(
+        (async () => {
+          const reg = await navigator.serviceWorker.getRegistration($1);
+          const worker = reg ? (reg.active || reg.waiting || reg.installing) : null;
+          if (!worker) {
+            return 'NO_WORKER';
+          }
+          return new Promise((resolve) => {
+            navigator.serviceWorker.addEventListener('message', (e) => resolve(e.data), {once: true});
+            worker.postMessage({ command: 'set-app-badge', value: 42 });
+          });
+        })();
+      )",
+      cross_site_sw_scope.spec());
+  ASSERT_EQ("OK", EvalJs(cross_site_frame_, set_badge_script));
+  badge_manager()->FlushReceiversForTesting();
+  EXPECT_TRUE(delegate()->cleared_badges().empty());
+  EXPECT_TRUE(delegate()->set_badges().empty());
+  EXPECT_EQ(99u, badge_manager()->GetBadgeValue(main_app_id()));
 }
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/ui/web_applications/web_app_badging_browsertest.cc b/chrome/browser/ui/web_applications/web_app_badging_browsertest.cc
index c782226..7e9abb5 100644
--- a/chrome/browser/ui/web_applications/web_app_badging_browsertest.cc
+++ b/chrome/browser/ui/web_applications/web_app_badging_browsertest.cc
@@ -25,6 +25,8 @@
 #include "content/public/browser/web_contents.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
+#include "net/dns/mock_host_resolver.h"
+#include "services/network/public/cpp/network_switches.h"
 
 using content::RenderFrameHost;
 
@@ -35,20 +37,30 @@
   WebAppBadgingBrowserTest()
       : cross_origin_https_server_(net::EmbeddedTestServer::TYPE_HTTPS) {}
 
+  // Host resolver rules must be set via command-line switches so out-of-process
+  // Network Services (e.g., on ChromeOS and Windows) receive the DNS mapping at
+  // startup. This allows early navigations in SetUpOnMainThread() to resolve
+  // cross-origin test hosts before Mojo IPC synchronization occurs.
+  void SetUpCommandLine(base::CommandLine* command_line) override {
+    WebAppBrowserTestBase::SetUpCommandLine(command_line);
+    command_line->AppendSwitchASCII(network::switches::kHostResolverRules,
+                                    "MAP * 127.0.0.1");
+  }
+
   void SetUpOnMainThread() override {
     WebAppBrowserTestBase::SetUpOnMainThread();
 
+    cross_origin_https_server_.SetSSLConfig(
+        net::EmbeddedTestServer::CERT_TEST_NAMES);
+    cross_origin_https_server_.AddDefaultHandlers(GetChromeTestDataDir());
     ASSERT_TRUE(cross_origin_https_server_.Start());
-    ASSERT_TRUE(embedded_test_server()->Start());
 
-    GURL cross_site_frame_url =
-        cross_origin_https_server_.GetURL("/web_app_badging/blank.html");
+    GURL cross_site_frame_url = cross_origin_https_server_.GetURL(
+        "b.test", "/web_app_badging/blank.html");
     cross_site_app_id_ = InstallPWA(cross_site_frame_url);
 
-    // Note: The url for the cross site frame is embedded in the query string.
     GURL start_url = embedded_https_test_server().GetURL(
-        "/web_app_badging/badging_with_frames_and_workers.html?url=" +
-        cross_site_frame_url.spec());
+        "/web_app_badging/badging_with_frames_and_workers.html");
     main_app_id_ = InstallPWA(start_url);
 
     GURL sub_start_url =
@@ -64,6 +76,10 @@
     apps::AppReadinessWaiter(profile(), sub_app_id_).Await();
 
     content::WebContents* web_contents = OpenApplication(main_app_id_);
+    ASSERT_TRUE(content::WaitForLoadStop(web_contents));
+    ASSERT_TRUE(content::NavigateIframeToURL(web_contents, "cross-site",
+                                             cross_site_frame_url));
+
     // There should be exactly 4 frames:
     // 1) The main frame.
     // 2) A frame containing a sub app.
@@ -71,7 +87,6 @@
     // 4) A sub frame in the app's scope.
     auto frames = CollectAllRenderFrameHosts(web_contents->GetPrimaryPage());
     ASSERT_EQ(4u, frames.size());
-
     main_frame_ = web_contents->GetPrimaryMainFrame();
     for (auto* frame : frames) {
       if (frame->GetLastCommittedURL() == sub_start_url) {
@@ -83,17 +98,17 @@
         cross_site_frame_ = frame;
       }
     }
-
     ASSERT_TRUE(main_frame_);
     ASSERT_TRUE(sub_app_frame_);
     ASSERT_TRUE(in_scope_frame_);
     ASSERT_TRUE(cross_site_frame_);
+    ASSERT_EQ(cross_site_frame_url, cross_site_frame_->GetLastCommittedURL());
 
-    // Register two service workers:
+    // Register two service workers on a.test:
     // 1) A service worker with a scope that applies to both the main app and
     // the sub app.
     // 2) A service worker with a scope that applies to the sub app only.
-    app_service_worker_scope_ = start_url.GetWithoutFilename();
+    // app_service_worker_scope_ = start_url.GetWithoutFilename();
     const std::string register_app_service_worker_script = content::JsReplace(
         kRegisterServiceWorkerScript, app_service_worker_scope_.spec());
     ASSERT_EQ("OK",
@@ -138,7 +153,7 @@
 
   void OnBadgeChanged() {
     // This is only set up to deal with one badge change at a time per app,
-    // in order to make asserting the result of a badge change easier.  A single
+    // in order to make asserting the result of a badge change easier. A single
     // service worker badge call may affect multiple apps within its scope.
     const size_t total_changes =
         delegate_->cleared_badges().size() + delegate_->set_badges().size();
@@ -238,6 +253,12 @@
         main_frame_, expected_badge_change_count);
   }
 
+  badging::TestBadgeManagerDelegate* delegate() { return delegate_; }
+
+  badging::BadgeManager* badge_manager() {
+    return badging::BadgeManagerFactory::GetForProfile(profile());
+  }
+
   const webapps::AppId& main_app_id() { return main_app_id_; }
   const webapps::AppId& sub_app_id() { return sub_app_id_; }
   const webapps::AppId& cross_site_app_id() { return cross_site_app_id_; }
@@ -261,7 +282,7 @@
   // Only the sub app is within this scope.
   GURL sub_app_service_worker_scope_;
 
-  // Frame badge updates affect the badge for at most 1 app.  However, a single
+  // Frame badge updates affect the badge for at most 1 app. However, a single
   // service worker badge update may affect multiple apps.
   size_t expected_badge_change_count_ = 0;
 
@@ -297,26 +318,115 @@
   net::EmbeddedTestServer cross_origin_https_server_;
 };
 
-// Tests that the badge for the main frame is not affected by changing the badge
-// of a cross site subframe.
+// Tests that a cross site subframe (in a third-party partitioned context)
+// cannot change the badge of the main frame or the cross site app.
 IN_PROC_BROWSER_TEST_F(WebAppBadgingBrowserTest,
-                       CrossSiteFrameCannotChangeMainFrameBadge) {
-  // Clearing from cross site frame should affect only the cross site app.
-  ExecuteScriptAndWaitForBadgeChange("navigator.clearAppBadge()",
-                                     cross_site_frame_);
+                       CrossSiteFrameCannotChangeBadge) {
+  // Set a baseline badge on the app from the first-party main frame.
+  ExecuteScriptAndWaitForBadgeChange("navigator.setAppBadge(99)", main_frame_);
   BadgeChange badge_change;
-  ASSERT_NO_FATAL_FAILURE(GetBadgeChange(cross_site_app_id(), &badge_change));
-  ASSERT_TRUE(badge_change.was_cleared_);
-  ASSERT_FALSE(badge_change.was_flagged_);
+  ASSERT_NO_FATAL_FAILURE(GetBadgeChange(main_app_id(), &badge_change));
+  EXPECT_EQ(99u, badge_change.last_badge_content_);
+  delegate()->ResetBadges();
 
-  // Setting from cross site frame should affect only the cross site app.
-  ExecuteScriptAndWaitForBadgeChange("navigator.setAppBadge(77)",
-                                     cross_site_frame_);
+  // Clearing from cross site frame should not clear or affect any badges.
+  ASSERT_TRUE(content::ExecJs(cross_site_frame_, "navigator.clearAppBadge()"));
+  badge_manager()->FlushReceiversForTesting();
+  EXPECT_TRUE(delegate()->cleared_badges().empty());
+  EXPECT_TRUE(delegate()->set_badges().empty());
+  EXPECT_EQ(99u, badge_manager()->GetBadgeValue(main_app_id()));
 
-  ASSERT_NO_FATAL_FAILURE(GetBadgeChange(cross_site_app_id(), &badge_change));
-  ASSERT_FALSE(badge_change.was_cleared_);
-  ASSERT_FALSE(badge_change.was_flagged_);
-  ASSERT_EQ(77u, badge_change.last_badge_content_);
+  // Setting from cross site frame should not overwrite or affect any badges.
+  ASSERT_TRUE(content::ExecJs(cross_site_frame_, "navigator.setAppBadge(77)"));
+  badge_manager()->FlushReceiversForTesting();
+  EXPECT_TRUE(delegate()->cleared_badges().empty());
+  EXPECT_TRUE(delegate()->set_badges().empty());
+  EXPECT_EQ(99u, badge_manager()->GetBadgeValue(main_app_id()));
+}
+
+// Tests that a service worker in a cross-site subframe (in a third-party
+// partitioned context) cannot change or clear badges for any app.
+IN_PROC_BROWSER_TEST_F(WebAppBadgingBrowserTest,
+                       CrossSiteServiceWorkerCannotChangeBadge) {
+  // Register a service worker inside the cross-site (b.test) subframe.
+  GURL cross_site_sw_scope =
+      cross_site_frame_->GetLastCommittedURL().GetWithoutFilename();
+  const std::string register_script = content::JsReplace(
+      R"(
+        (async () => {
+          const reg = await navigator.serviceWorker.register(
+              '/web_app_badging/service_worker.js', {scope: $1});
+          if (reg.active) {
+            return 'OK';
+          }
+          const worker = reg.waiting || reg.installing;
+          if (!worker) {
+            return 'NO_WORKER';
+          }
+          if (worker.state === 'activated') {
+            return 'OK';
+          }
+          return new Promise((resolve, reject) => {
+            worker.addEventListener('statechange', () => {
+              if (worker.state === 'activated') resolve('OK');
+              if (worker.state === 'redundant') reject('worker became redundant');
+            });
+          });
+        })();
+      )",
+      cross_site_sw_scope.spec());
+  ASSERT_EQ("OK", EvalJs(cross_site_frame_, register_script));
+
+  // Set a baseline badge on the app from the first-party main frame.
+  ExecuteScriptAndWaitForBadgeChange("navigator.setAppBadge(99)", main_frame_);
+  BadgeChange badge_change;
+  ASSERT_NO_FATAL_FAILURE(GetBadgeChange(main_app_id(), &badge_change));
+  EXPECT_EQ(99u, badge_change.last_badge_content_);
+  delegate()->ResetBadges();
+
+  // Instruct the partitioned service worker to clear a badge.
+  const std::string clear_badge_script = content::JsReplace(
+      R"(
+        (async () => {
+          const reg = await navigator.serviceWorker.getRegistration($1);
+          const worker = reg ? (reg.active || reg.waiting || reg.installing) : null;
+          if (!worker) {
+            return 'NO_WORKER';
+          }
+          return new Promise((resolve) => {
+            navigator.serviceWorker.addEventListener('message', (e) => resolve(e.data), {once: true});
+            worker.postMessage({ command: 'clear-app-badge' });
+          });
+        })();
+      )",
+      cross_site_sw_scope.spec());
+  ASSERT_EQ("OK", EvalJs(cross_site_frame_, clear_badge_script));
+  badge_manager()->FlushReceiversForTesting();
+  EXPECT_TRUE(delegate()->cleared_badges().empty());
+  EXPECT_TRUE(delegate()->set_badges().empty());
+  EXPECT_EQ(99u, badge_manager()->GetBadgeValue(main_app_id()));
+
+  // Instruct the partitioned service worker to set a badge.
+  const std::string set_badge_script = content::JsReplace(
+      R"(
+        (async () => {
+          const reg = await navigator.serviceWorker.getRegistration($1);
+          const worker = reg ? (reg.active || reg.waiting || reg.installing) : null;
+          if (!worker) {
+            return 'NO_WORKER';
+          }
+          return new Promise((resolve) => {
+            navigator.serviceWorker.addEventListener('message', (e) => resolve(e.data), {once: true});
+            worker.postMessage({ command: 'set-app-badge', value: 42 });
+          });
+        })();
+      )",
+      cross_site_sw_scope.spec());
+  ASSERT_EQ("OK", EvalJs(cross_site_frame_, set_badge_script));
+  badge_manager()->FlushReceiversForTesting();
+  EXPECT_TRUE(delegate()->cleared_badges().empty());
+  EXPECT_TRUE(delegate()->set_badges().empty());
+  EXPECT_EQ(99u, badge_manager()->GetBadgeValue(main_app_id()));
 }
 
 // Tests that setting the badge to an integer will be propagated across
diff --git a/chrome/test/data/web_app_badging/badging_with_frames_and_workers.html b/chrome/test/data/web_app_badging/badging_with_frames_and_workers.html
index 2ca8c7a..4ea07004 100644
--- a/chrome/test/data/web_app_badging/badging_with_frames_and_workers.html
+++ b/chrome/test/data/web_app_badging/badging_with_frames_and_workers.html
@@ -9,11 +9,6 @@
   <script type="text/javascript">
     'use strict';
 
-    // Note: The url for the cross site frame is embedded in the query string.
-    const crossFrame = document.getElementById('cross-site');
-    const crossSiteUrl = new URLSearchParams(location.search).get('url');
-    crossFrame.src = crossSiteUrl;
-
     // Copy of //content/test/data/service_worker/create_service_worker.html.
 
     // Simulates navigator.serviceWorker.ready, which can't be used since this
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.