Medium watch CVSS 4.3 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Permissions
DescriptionRace condition in Permissions
ComponentPermissions
Bug ClassRace
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.
Tracker501437087
Fix commit2c8512aeee6d (chromium/src) +161/-59
CWECWE-367 (TOCTOU race)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-10 (per vendor advisory)
Fix landed2026-07-21 (public git)
Disclosed2026-08-25
Patch gap35 days — fix public in source before the advisory

Background

`PermissionRequestManager`
The per-WebContents queue of pending permission requests. Requests()[0] is the request currently being shown, and each request carries a requesting_origin().
Android OS notification prompt
On Android, granting web notifications can also require the system POST_NOTIFICATIONS permission. Chrome shows the OS dialog asynchronously and resolves the pending Chrome request when the OS dialog returns, via PermissionUtil → JNI → internal::ResolveNotificationsPermissionRequest / internal::DismissNotificationsPermissionRequest.
Entry points
Two UI surfaces drive this flow: the quiet/blocked permission message (PermissionBlockedMessageDelegate) and the Page Info permissions subpage (PageInfoPermissionsController, e.g. its “Subscribe” action).

Root Cause Analysis

Before the patch, internal::ResolveNotificationsPermissionRequest and internal::DismissNotificationsPermissionRequest acted on whatever request was at the front of the WebContents’ PermissionRequestManager, as long as a request was in progress and its type was ContentSettingsType::NOTIFICATIONS. They did not check which origin that request came from. The decision comes back asynchronously after an OS dialog (or from Page Info), and the tab can navigate in the meantime (the commit cites “background navigation”). The queued notification request may therefore belong to a different origin from the page the user was actually deciding about. A stale “allow” could then grant notifications to an origin the user never approved, or a stale dismiss could cancel another origin’s request: a time-of-check/time-of-use race between the UI decision and the request queue.

The fix sends the origin through every caller: PageInfoPermissionsController passes mPageUrl, and PermissionBlockedMessageDelegate captures requests[0]->requesting_origin() at click time and passes it via ResolveWithOSPrompt → permissions::ResolvePermissionWithOSPrompt → PermissionUtil.handlePermissionPromptAllow. Both native resolvers now also require url::Origin::Create(Requests()[0]->requesting_origin()) == url::Origin::Create(requesting_origin), and leave the request untouched if they differ, as the new *_MismatchOrigin tests check.

Key insight
An asynchronous permission decision was applied to “the current notification request in this tab” rather than to the request the user saw. The fix ties each decision to the requesting origin and ignores it if the active request’s origin no longer matches.

Attack Path

  1. User starts a notification decision On Chrome for Android, the user acts on a notification permission prompt for site A, via the blocked-permission message or the Page Info “Subscribe” flow. This shows the Android OS notification dialog asynchronously.
  2. Tab state changes in the window While the OS dialog is pending, the tab navigates (the commit message cites background navigation), and a notification permission request from a different origin becomes Requests()[0] in the same PermissionRequestManager (inferred: e.g. an attacker-controlled page that requests notifications right after navigation).
  3. Stale response applied Pre-patch, when the OS dialog returned, ResolveNotificationsPermissionRequest checked only the request type and accepted (or DismissNotificationsPermissionRequest dismissed) the front request, whatever its origin.
  4. Unintended permission state The other origin gets a notification grant the user never approved for it (inferred), or a legitimate request is dismissed.

Impact Assessment

The race lets a notification permission decision intended for one origin be applied to a different origin’s pending request in the same tab. That could give an attacker origin a persistent notification grant, usable for spam or phishing notifications (inferred). It needs Chrome on Android, user interaction with the OS notification dialog or Page Info, and correct timing of a navigation plus a new notification request while that dialog is pending. The advisory gives only “Race condition in Permissions” and does not report in-the-wild exploitation.

Changed Functions

FunctionChangeNotes
GURL
chrome/browser/permissions/permission_blocked_message_delegate_android.h
modified
WebContents
chrome/browser/permissions/permission_blocked_message_delegate_android.h
modified
if
components/page_info/android/java/src/org/chromium/components/page_info/PageInfoPermissionsController.java
modified
if
components/permissions/android/android_permission_util.cc
modified
switch
components/permissions/android/android_permission_util.cc
modified
GURL
components/permissions/android/android_permission_util.h
modified
WebContents
components/permissions/android/android_permission_util.h
modified

Files Changed

  • components/permissions/android/android_permission_util.cc
  • chrome/browser/permissions/permission_blocked_message_delegate_android.cc
  • components/permissions/android/BUILD.gn
  • chrome/browser/permissions/permission_blocked_message_delegate_android.h
  • components/permissions/android/android_permission_util.h
  • components/page_info/android/java/src/org/chromium/components/page_info/PageInfoPermissionsController.java
  • chrome/android/junit/src/org/chromium/chrome/browser/page_info/PageInfoPermissionsControllerTest.java
  • components/permissions/android/android_permission_util_unittest.cc
  • components/permissions/android/java/src/org/chromium/components/permissions/PermissionUtil.java
  • chrome/browser/permissions/permission_blocked_message_delegate_android_unittest.cc

Audit Directions

  • Async UI decisions applied to "current" state
    Look for OS-dialog or bottom-sheet callbacks (location, camera, Bluetooth, storage-access, file pickers) that re-read Requests()[0] or the active request when they complete. Every one should carry and compare the identity of the request (origin, or better a request ID) that was shown.
  • Origin vs. request identity
    The fix compares origins, so two different requests from the same origin are still interchangeable. Check whether any flow needs stronger binding (request ID, embedding origin, navigation ID).
  • Fallback grants on mismatch
    In PageInfoPermissionsController, a false from resolveNotificationsPermissionRequest (now also returned on origin mismatch) falls back to setContentSettingDefaultScope(... ALLOW). Check that this fallback always targets the origin the user actually consented for and cannot be steered by navigation.
From 2c8512aeee6d51a3f606a09998dfca190959f1cd Mon Sep 17 00:00:00 2001
From: Jochen Eisinger <jochen@chromium.org>
Date: Tue, 21 Jul 2026 01:58:40 -0700
Subject: [PATCH] Bind Android notification resolution to requesting URL

When notification permissions are resolved or dismissed via Android OS
dialogs or PageInfo, ensure that the permission resolution callback
is strictly bound to the requesting URL. If the URL's origin does not
match the active permission request's origin (e.g., due to background
navigation), the stale OS response is ignored and the active request
remains intact in the queue.

BUG=501437087
TAG=agy
CONV=94c00056-b490-48fb-93da-e4fac593143e

Change-Id: Iaca9f120b1560bb1e0609e5783e8bbf997f01d52
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8122235
Commit-Queue: Jochen Eisinger <jochen@chromium.org>
Reviewed-by: Balazs Engedy <engedy@chromium.org>
Reviewed-by: Antonio Sartori <antoniosartori@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1665293}
---
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/page_info/PageInfoPermissionsControllerTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/page_info/PageInfoPermissionsControllerTest.java
index eb900e6..c946bff 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/page_info/PageInfoPermissionsControllerTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/page_info/PageInfoPermissionsControllerTest.java
@@ -58,6 +58,7 @@
     @Mock private Context mContext;
     @Mock private PermissionUtil.Natives mPermissionUtilJni;
 
+    private GURL mPageUrl;
     private PageInfoPermissionsController mController;
     private boolean mRequestAndroidPermissionsResult;
     private AndroidPermissionRequester.RequestDelegate mRequestDelegateCaptured;
@@ -65,11 +66,12 @@
     @Before
     public void setUp() {
         PermissionUtilJni.setInstanceForTesting(mPermissionUtilJni);
+        mPageUrl = new GURL("https://example.com");
 
         when(mRowView.getContext()).thenReturn(mContext);
         when(mContext.getResources())
                 .thenReturn(ContextUtils.getApplicationContext().getResources());
-        when(mMainController.getURL()).thenReturn(new GURL("https://example.com"));
+        when(mMainController.getURL()).thenReturn(mPageUrl);
         when(mWebContents.getTopLevelNativeWindow()).thenReturn(mWindowAndroid);
 
         mController =
@@ -111,7 +113,7 @@
     @Test
     public void testOnNotificationSubscribeClicked_RequestsPermission_Granted() {
         when(mPermissionUtilJni.resolveNotificationsPermissionRequest(
-                        mWebContents, ContentSetting.ALLOW))
+                        mWebContents, mPageUrl, ContentSetting.ALLOW))
                 .thenReturn(true);
         mRequestAndroidPermissionsResult = true;
         HistogramWatcher histogramWatcher =
@@ -123,7 +125,8 @@
         mRequestDelegateCaptured.onAndroidPermissionAccepted();
 
         verify(mPermissionUtilJni)
-                .resolveNotificationsPermissionRequest(mWebContents, ContentSetting.ALLOW);
+                .resolveNotificationsPermissionRequest(
+                        mWebContents, mPageUrl, ContentSetting.ALLOW);
         histogramWatcher.assertExpected();
     }
 
@@ -138,33 +141,35 @@
 
         mRequestDelegateCaptured.onAndroidPermissionCanceled();
 
-        verify(mPermissionUtilJni).dismissNotificationsPermissionRequest(mWebContents);
+        verify(mPermissionUtilJni).dismissNotificationsPermissionRequest(mWebContents, mPageUrl);
         histogramWatcher.assertExpected();
     }
 
     @Test
     public void testOnNotificationSubscribeClicked_PermissionAlreadyGranted() {
         when(mPermissionUtilJni.resolveNotificationsPermissionRequest(
-                        mWebContents, ContentSetting.ALLOW))
+                        mWebContents, mPageUrl, ContentSetting.ALLOW))
                 .thenReturn(true);
         mRequestAndroidPermissionsResult = false;
         mController.onNotificationSubscribeClicked();
 
         verify(mPermissionUtilJni)
-                .resolveNotificationsPermissionRequest(mWebContents, ContentSetting.ALLOW);
+                .resolveNotificationsPermissionRequest(
+                        mWebContents, mPageUrl, ContentSetting.ALLOW);
     }
 
     @Test
     public void testOnNotificationSubscribeClicked_NullWindow() {
         when(mPermissionUtilJni.resolveNotificationsPermissionRequest(
-                        mWebContents, ContentSetting.ALLOW))
+                        mWebContents, mPageUrl, ContentSetting.ALLOW))
                 .thenReturn(true);
         when(mWebContents.getTopLevelNativeWindow()).thenReturn(null);
 
         mController.onNotificationSubscribeClicked();
 
         verify(mPermissionUtilJni)
-                .resolveNotificationsPermissionRequest(mWebContents, ContentSetting.ALLOW);
+                .resolveNotificationsPermissionRequest(
+                        mWebContents, mPageUrl, ContentSetting.ALLOW);
     }
 
     @Test
@@ -182,7 +187,8 @@
         mController.onSubpageRemoved();
 
         verify(mPermissionUtilJni)
-                .resolveNotificationsPermissionRequest(mWebContents, ContentSetting.BLOCK);
+                .resolveNotificationsPermissionRequest(
+                        mWebContents, mPageUrl, ContentSetting.BLOCK);
     }
 
     @Test
@@ -200,6 +206,7 @@
         mController.onPermissionsReset();
 
         verify(mPermissionUtilJni)
-                .resolveNotificationsPermissionRequest(mWebContents, ContentSetting.DEFAULT);
+                .resolveNotificationsPermissionRequest(
+                        mWebContents, mPageUrl, ContentSetting.DEFAULT);
     }
 }
diff --git a/chrome/browser/permissions/permission_blocked_message_delegate_android.cc b/chrome/browser/permissions/permission_blocked_message_delegate_android.cc
index 5600a09..8bdbe42 100644
--- a/chrome/browser/permissions/permission_blocked_message_delegate_android.cc
+++ b/chrome/browser/permissions/permission_blocked_message_delegate_android.cc
@@ -35,6 +35,7 @@
 #include "ui/android/window_android.h"
 #include "ui/base/l10n/l10n_util.h"
 #include "ui/strings/grit/ui_strings.h"
+#include "url/gurl.h"
 
 namespace {
 
@@ -315,13 +316,23 @@
   messages::MessageDispatcherBridge::Get()->DismissMessage(
       message_.get(), messages::DismissReason::PRIMARY_ACTION);
 
-  ResolveWithOSPrompt(GetContentSettingsType());
+  if (!delegate_->permission_prompt()) {
+    return;
+  }
+  const std::vector<base::SafeRef<permissions::PermissionRequest>>& requests =
+      delegate_->permission_prompt()->Requests();
+  if (requests.empty()) {
+    return;
+  }
+  ResolveWithOSPrompt(GetContentSettingsType(),
+                      requests[0]->requesting_origin());
 }
 
 void PermissionBlockedMessageDelegate::ResolveWithOSPrompt(
-    ContentSettingsType content_settings_type) {
-  permissions::ResolvePermissionWithOSPrompt(web_contents_,
-                                             content_settings_type);
+    ContentSettingsType content_settings_type,
+    const GURL& requesting_origin) {
+  permissions::ResolvePermissionWithOSPrompt(
+      web_contents_, content_settings_type, requesting_origin);
 }
 
 void PermissionBlockedMessageDelegate::HandleLoudDismissCallback(
diff --git a/chrome/browser/permissions/permission_blocked_message_delegate_android.h b/chrome/browser/permissions/permission_blocked_message_delegate_android.h
index 9671246f..1cf74bc 100644
--- a/chrome/browser/permissions/permission_blocked_message_delegate_android.h
+++ b/chrome/browser/permissions/permission_blocked_message_delegate_android.h
@@ -16,6 +16,8 @@
 #include "components/permissions/permissions_client.h"
 #include "content/public/browser/web_contents_observer.h"
 
+class GURL;
+
 namespace content {
 class WebContents;
 }
@@ -75,7 +77,8 @@
   void OnWebContentsFocused(
       content::RenderWidgetHost* render_widget_host) override;
 
-  virtual void ResolveWithOSPrompt(ContentSettingsType content_settings_type);
+  virtual void ResolveWithOSPrompt(ContentSettingsType content_settings_type,
+                                   const GURL& requesting_origin);
 
  private:
   friend class PermissionBlockedMessageDelegateAndroidTest;
diff --git a/components/page_info/android/java/src/org/chromium/components/page_info/PageInfoPermissionsController.java b/components/page_info/android/java/src/org/chromium/components/page_info/PageInfoPermissionsController.java
index 46fd057..2afa356 100644
--- a/components/page_info/android/java/src/org/chromium/components/page_info/PageInfoPermissionsController.java
+++ b/components/page_info/android/java/src/org/chromium/components/page_info/PageInfoPermissionsController.java
@@ -140,7 +140,7 @@
         // not explicitly grant the permission via clicking the "Subscribe" button.
         if (mHasRequestedNotificationsPermission) {
             PermissionUtil.resolveNotificationsPermissionRequest(
-                    mWebContents, ContentSetting.BLOCK);
+                    mWebContents, mPageUrl, ContentSetting.BLOCK);
             // Reset the requested permission state to false, as the permission has been denied and
             // is not longer in request. This will ensure that the notification permission request
             // will not be accidentally shown again when the user navigates back to the permission
@@ -313,7 +313,7 @@
     public void onPermissionsReset() {
         if (mHasRequestedNotificationsPermission) {
             PermissionUtil.resolveNotificationsPermissionRequest(
-                    mWebContents, ContentSetting.DEFAULT);
+                    mWebContents, mPageUrl, ContentSetting.DEFAULT);
             mHasRequestedNotificationsPermission = false;
         }
 
@@ -381,7 +381,7 @@
 
         if (isGranted) {
             if (!PermissionUtil.resolveNotificationsPermissionRequest(
-                    mWebContents, ContentSetting.ALLOW)) {
+                    mWebContents, mPageUrl, ContentSetting.ALLOW)) {
                 // If the permission request does not exist anymore, just grant notification
                 // permission to the page.
                 WebsitePreferenceBridge.setContentSettingDefaultScope(
@@ -392,7 +392,7 @@
                         ContentSetting.ALLOW);
             }
         } else {
-            PermissionUtil.dismissNotificationsPermissionRequest(mWebContents);
+            PermissionUtil.dismissNotificationsPermissionRequest(mWebContents, mPageUrl);
         }
 
         // `updateRowIfNeeded` will update the permission row in the main view of PageInfo. It will
diff --git a/components/permissions/android/android_permission_util.cc b/components/permissions/android/android_permission_util.cc
index a64a0c3..dcb0e8be 100644
--- a/components/permissions/android/android_permission_util.cc
+++ b/components/permissions/android/android_permission_util.cc
@@ -21,6 +21,9 @@
 #include "content/public/browser/web_contents.h"
 #include "third_party/jni_zero/default_conversions.h"
 #include "ui/android/window_android.h"
+#include "url/android/gurl_android.h"
+#include "url/gurl.h"
+#include "url/origin.h"
 
 // Must come after all headers that specialize FromJniType() / ToJniType().
 #include "components/permissions/android/jni_headers/AndroidPermissionRequester_jni.h"
@@ -219,20 +222,22 @@
 }
 
 void ResolvePermissionWithOSPrompt(content::WebContents* web_contents,
-                                   ContentSettingsType content_settings_type) {
+                                   ContentSettingsType content_settings_type,
+                                   const GURL& requesting_origin) {
   DCHECK(web_contents);
   auto* window_android = web_contents->GetNativeView()->GetWindowAndroid();
   DCHECK(window_android);
 
   JNIEnv* env = base::android::AttachCurrentThread();
   Java_PermissionUtil_handlePermissionPromptAllow(
-      env, window_android, web_contents,
+      env, window_android, web_contents, requesting_origin,
       static_cast<int>(content_settings_type));
 }
 
 namespace internal {
 
 bool ResolveNotificationsPermissionRequest(content::WebContents* web_contents,
+                                           const GURL& requesting_origin,
                                            ContentSetting setting) {
   if (!web_contents) {
     return false;
@@ -244,9 +249,12 @@
     return false;
   }
   if (permission_request_manager->IsRequestInProgress() &&
-      permission_request_manager->Requests().size() > 0 &&
+      !permission_request_manager->Requests().empty() &&
       permission_request_manager->Requests()[0]->GetContentSettingsType() ==
-          ContentSettingsType::NOTIFICATIONS) {
+          ContentSettingsType::NOTIFICATIONS &&
+      url::Origin::Create(
+          permission_request_manager->Requests()[0]->requesting_origin()) ==
+          url::Origin::Create(requesting_origin)) {
     switch (setting) {
       case CONTENT_SETTING_ALLOW:
         if (!permission_request_manager->ShouldCurrentRequestUseQuietUI()) {
@@ -286,7 +294,8 @@
   return false;
 }
 
-void DismissNotificationsPermissionRequest(content::WebContents* web_contents) {
+void DismissNotificationsPermissionRequest(content::WebContents* web_contents,
+                                           const GURL& requesting_origin) {
   if (!web_contents) {
     return;
   }
@@ -297,9 +306,12 @@
     return;
   }
   if (permission_request_manager->IsRequestInProgress() &&
-      permission_request_manager->Requests().size() > 0 &&
+      !permission_request_manager->Requests().empty() &&
       permission_request_manager->Requests()[0]->GetContentSettingsType() ==
-          ContentSettingsType::NOTIFICATIONS) {
+          ContentSettingsType::NOTIFICATIONS &&
+      url::Origin::Create(
+          permission_request_manager->Requests()[0]->requesting_origin()) ==
+          url::Origin::Create(requesting_origin)) {
     permission_request_manager->Dismiss(/*prompt_options=*/std::monostate());
   }
 }
@@ -316,17 +328,20 @@
 // valid.
 static void JNI_PermissionUtil_DismissNotificationsPermissionRequest(
     JNIEnv* env,
-    content::WebContents* web_contents) {
-  permissions::internal::DismissNotificationsPermissionRequest(web_contents);
+    content::WebContents* web_contents,
+    const GURL& requesting_origin) {
+  permissions::internal::DismissNotificationsPermissionRequest(
+      web_contents, requesting_origin);
 }
 
 static bool JNI_PermissionUtil_ResolveNotificationsPermissionRequest(
     JNIEnv* env,
     content::WebContents* web_contents,
+    const GURL& requesting_origin,
     int32_t content_setting) {
   ContentSetting setting = static_cast<ContentSetting>(content_setting);
   return permissions::internal::ResolveNotificationsPermissionRequest(
-      web_contents, setting);
+      web_contents, requesting_origin, setting);
 }
 // TODO(crbug.com/463333225): Clean this provisional function name up if
 // Clapper is launched or removed.
diff --git a/components/permissions/android/android_permission_util.h b/components/permissions/android/android_permission_util.h
index 01d6ac6..e2ccb560e 100644
--- a/components/permissions/android/android_permission_util.h
+++ b/components/permissions/android/android_permission_util.h
@@ -12,6 +12,8 @@
 #include "components/content_settings/core/common/content_settings.h"
 #include "components/content_settings/core/common/content_settings_types.h"
 
+class GURL;
+
 namespace content {
 class WebContents;
 }
@@ -24,12 +26,15 @@
 
 namespace internal {
 
-// Resolves an ongoing notification permission request, if there is one. Returns
-// false if no ongoing notification permission request was found.
+// Resolves an ongoing notification permission request, if there is one and its
+// requesting origin matches `requesting_origin`. Returns false if no ongoing
+// notification permission request was found or if the origin does not match.
 bool ResolveNotificationsPermissionRequest(content::WebContents* web_contents,
+                                           const GURL& requesting_origin,
                                            ContentSetting content_setting);
 
-void DismissNotificationsPermissionRequest(content::WebContents* web_contents);
+void DismissNotificationsPermissionRequest(content::WebContents* web_contents,
+                                           const GURL& requesting_origin);
 
 }  // namespace internal
 
@@ -122,7 +127,8 @@
 // system permission. If granted, it accepts the request; otherwise, it
 // dismisses it.
 void ResolvePermissionWithOSPrompt(content::WebContents* web_contents,
-                                   ContentSettingsType content_settings_type);
+                                   ContentSettingsType content_settings_type,
+                                   const GURL& requesting_origin);
 
 }  // namespace permissions
 
diff --git a/components/permissions/android/java/src/org/chromium/components/permissions/PermissionUtil.java b/components/permissions/android/java/src/org/chromium/components/permissions/PermissionUtil.java
index 09e61ec..eb7faa0 100644
--- a/components/permissions/android/java/src/org/chromium/components/permissions/PermissionUtil.java
+++ b/components/permissions/android/java/src/org/chromium/components/permissions/PermissionUtil.java
@@ -24,6 +24,7 @@
 import org.chromium.ui.base.WindowAndroid;
 import org.chromium.ui.permissions.ContextualNotificationPermissionRequester;
 import org.chromium.ui.permissions.PermissionCallback;
+import org.chromium.url.GURL;
 
 import java.util.Arrays;
 
@@ -288,10 +289,12 @@
     public static void handlePermissionPromptAllow(
             @JniType("ui::WindowAndroid*") WindowAndroid window,
             @JniType("content::WebContents*") WebContents webContents,
+            @JniType("GURL") GURL requestingOrigin,
             @ContentSettingsType.EnumType int contentSettingsType) {
         requestAndResolveNotificationsPermissionRequest(
                 window,
                 webContents,
+                requestingOrigin,
                 () -> {
                     boolean granted = window.hasPermission(Manifest.permission.POST_NOTIFICATIONS);
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/permissions/permission_blocked_message_delegate_android_unittest.cc b/chrome/browser/permissions/permission_blocked_message_delegate_android_unittest.cc
index f463d4de..59fb427 100644
--- a/chrome/browser/permissions/permission_blocked_message_delegate_android_unittest.cc
+++ b/chrome/browser/permissions/permission_blocked_message_delegate_android_unittest.cc
@@ -21,6 +21,7 @@
 #include "components/strings/grit/components_strings.h"
 #include "components/url_formatter/elide_url.h"
 #include "ui/base/l10n/l10n_util.h"
+#include "url/gurl.h"
 
 namespace {
 
@@ -84,7 +85,10 @@
       : PermissionBlockedMessageDelegate(web_contents, std::move(delegate)) {}
   ~TestPermissionBlockedMessageDelegate() override = default;
 
-  void ResolveWithOSPrompt(ContentSettingsType content_settings_type) override {
+  void ResolveWithOSPrompt(ContentSettingsType content_settings_type,
+                           const GURL& requesting_origin) override {
+    EXPECT_EQ(requesting_origin,
+              GURL(permissions::MockPermissionRequest::kDefaultOrigin));
     // Simulate Java callback calling Accept on native.
     delegate_->Accept();
   }
diff --git a/components/permissions/android/android_permission_util_unittest.cc b/components/permissions/android/android_permission_util_unittest.cc
index e642ab55..a1ec759b 100644
--- a/components/permissions/android/android_permission_util_unittest.cc
+++ b/components/permissions/android/android_permission_util_unittest.cc
@@ -15,6 +15,7 @@
 #include "components/permissions/test/test_permissions_client.h"
 #include "content/public/test/test_renderer_host.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "url/gurl.h"
 
 namespace permissions {
 
@@ -60,14 +61,16 @@
 
 TEST_F(AndroidPermissionUtilTest, ResolvePermissionRequest_NoManager) {
   // Pass nullptr as web_contents.
-  internal::ResolveNotificationsPermissionRequest(nullptr,
-                                                  CONTENT_SETTING_ALLOW);
+  internal::ResolveNotificationsPermissionRequest(
+      nullptr, GURL(MockPermissionRequest::kDefaultOrigin),
+      CONTENT_SETTING_ALLOW);
   // Should not crash.
 }
 
 TEST_F(AndroidPermissionUtilTest, ResolvePermissionRequest_NoRequests) {
-  internal::ResolveNotificationsPermissionRequest(web_contents(),
-                                                  CONTENT_SETTING_ALLOW);
+  internal::ResolveNotificationsPermissionRequest(
+      web_contents(), GURL(MockPermissionRequest::kDefaultOrigin),
+      CONTENT_SETTING_ALLOW);
   // Should not crash.
 }
 
@@ -76,8 +79,23 @@
   AddRequest(RequestType::kGeolocation, &state);
 
   // Call with Notifications type.
-  internal::ResolveNotificationsPermissionRequest(web_contents(),
-                                                  CONTENT_SETTING_ALLOW);
+  internal::ResolveNotificationsPermissionRequest(
+      web_contents(), GURL(MockPermissionRequest::kDefaultOrigin),
+      CONTENT_SETTING_ALLOW);
+
+  // Request should still be in progress and not decided.
+  EXPECT_TRUE(manager_->IsRequestInProgress());
+  EXPECT_FALSE(state.granted);
+  EXPECT_FALSE(state.finished);
+  EXPECT_FALSE(state.cancelled);
+}
+
+TEST_F(AndroidPermissionUtilTest, ResolvePermissionRequest_MismatchOrigin) {
+  MockPermissionRequest::MockPermissionRequestState state;
+  AddRequest(RequestType::kNotifications, &state);
+
+  internal::ResolveNotificationsPermissionRequest(
+      web_contents(), GURL("https://mismatch.com"), CONTENT_SETTING_ALLOW);
 
   // Request should still be in progress and not decided.
   EXPECT_TRUE(manager_->IsRequestInProgress());
@@ -91,8 +109,9 @@
   MockPermissionRequest::MockPermissionRequestState state;
   AddRequest(RequestType::kNotifications, &state);
 
-  internal::ResolveNotificationsPermissionRequest(web_contents(),
-                                                  CONTENT_SETTING_ALLOW);
+  internal::ResolveNotificationsPermissionRequest(
+      web_contents(), GURL(MockPermissionRequest::kDefaultOrigin),
+      CONTENT_SETTING_ALLOW);
 
   EXPECT_TRUE(state.granted);
   EXPECT_FALSE(state.cancelled);
@@ -107,8 +126,9 @@
   MockPermissionRequest::MockPermissionRequestState state;
   AddRequest(RequestType::kNotifications, &state);
 
-  internal::ResolveNotificationsPermissionRequest(web_contents(),
-                                                  CONTENT_SETTING_BLOCK);
+  internal::ResolveNotificationsPermissionRequest(
+      web_contents(), GURL(MockPermissionRequest::kDefaultOrigin),
+      CONTENT_SETTING_BLOCK);
 
   EXPECT_FALSE(state.granted);
   EXPECT_FALSE(state.cancelled);
@@ -123,8 +143,9 @@
   MockPermissionRequest::MockPermissionRequestState state;
   AddRequest(RequestType::kNotifications, &state);
 
-  internal::ResolveNotificationsPermissionRequest(web_contents(),
-                                                  CONTENT_SETTING_DEFAULT);
+  internal::ResolveNotificationsPermissionRequest(
+      web_contents(), GURL(MockPermissionRequest::kDefaultOrigin),
+      CONTENT_SETTING_DEFAULT);
 
   // Default triggers Dismiss(), which calls Cancelled().
   EXPECT_TRUE(state.cancelled);
@@ -137,12 +158,14 @@
 
 TEST_F(AndroidPermissionUtilTest, DismissPermissionRequest_NoManager) {
   // Pass nullptr as web_contents.
-  internal::DismissNotificationsPermissionRequest(nullptr);
+  internal::DismissNotificationsPermissionRequest(
+      nullptr, GURL(MockPermissionRequest::kDefaultOrigin));
   // Should not crash.
 }
 
 TEST_F(AndroidPermissionUtilTest, DismissPermissionRequest_NoRequests) {
-  internal::DismissNotificationsPermissionRequest(web_contents());
+  internal::DismissNotificationsPermissionRequest(
+      web_contents(), GURL(MockPermissionRequest::kDefaultOrigin));
   // Should not crash.
 }
 
@@ -151,7 +174,22 @@
   AddRequest(RequestType::kGeolocation, &state);
 
   // Call with Notifications type.
-  internal::DismissNotificationsPermissionRequest(web_contents());
+  internal::DismissNotificationsPermissionRequest(
+      web_contents(), GURL(MockPermissionRequest::kDefaultOrigin));
+
+  // Request should still be in progress and not decided.
+  EXPECT_TRUE(manager_->IsRequestInProgress());
+  EXPECT_FALSE(state.granted);
+  EXPECT_FALSE(state.finished);
+  EXPECT_FALSE(state.cancelled);
+}
+
+TEST_F(AndroidPermissionUtilTest, DismissPermissionRequest_MismatchOrigin) {
+  MockPermissionRequest::MockPermissionRequestState state;
+  AddRequest(RequestType::kNotifications, &state);
+
+  internal::DismissNotificationsPermissionRequest(web_contents(),
+                                                  GURL("https://mismatch.com"));
 
   // Request should still be in progress and not decided.
   EXPECT_TRUE(manager_->IsRequestInProgress());
@@ -164,7 +202,8 @@
   MockPermissionRequest::MockPermissionRequestState state;
   AddRequest(RequestType::kNotifications, &state);
 
-  internal::DismissNotificationsPermissionRequest(web_contents());
+  internal::DismissNotificationsPermissionRequest(
+      web_contents(), GURL(MockPermissionRequest::kDefaultOrigin));
 
   EXPECT_TRUE(state.cancelled);
   EXPECT_FALSE(state.granted);
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential cross-origin notification grant via state confusion in Android Clapper Loud

Project Fortify, 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 Chrome Security team.

Overview: A potential logic error in the Android “Clapper Loud” notification flow allows a website to obtain notification permissions intended for a different origin. If a cross-origin navigation occurs while the Android system permission dialog is pending, the approval callback is erroneously applied to the new page’s request. This bypasses origin validation and grants persistent permission to the new site.

Affected files:

  • components/permissions/android/android_permission_util.cc
  • chrome/browser/permissions/permission_blocked_message_delegate_android.cc
  • components/permissions/android/java/src/org/chromium/components/permissions/PermissionUtil.java
  • components/permissions/permission_request_manager.cc

Estimated timestamp from git blame: 2026-02-11

Summary

A potential state confusion vulnerability exists in the Android “Clapper Loud” notification permission flow (kPermissionsAndroidClapperLoud), allowing for cross-origin permission grants. The asynchronous Android system permission dialog’s callback is bound to the WebContents rather than a specific PermissionRequest or origin. If a cross-origin navigation occurs while the OS dialog is visible, the native request queue is cleared. If the new page immediately requests notifications, the subsequent OS callback will resolve whichever request is currently at the head of the PermissionRequestManager queue, effectively granting permission to an origin the user never explicitly approved.

Technical Details

  1. When the user accepts the initial Chrome “Clapper Loud” message UI, PermissionBlockedMessageDelegate::HandleLoudPrimaryActionClick (chrome/browser/permissions/permission_blocked_message_delegate_android.cc) dismisses the message and triggers ResolveWithOSPrompt.
  2. This calls into Java (PermissionUtil.java:requestAndResolveNotificationsPermissionRequest), creating an anonymous RequestDelegate that requests the POST_NOTIFICATIONS permission from Android. This delegate captures the WebContents but fails to record the origin or a unique request ID associated with the prompt.
  3. While the Android system dialog is visible, the main thread remains unblocked. If the renderer executes a cross-origin navigation, PermissionRequestManager::DidFinishNavigation (components/permissions/permission_request_manager.cc) is invoked. This calls CleanUpRequests, which clears the active and pending request queues but cannot programmatically dismiss the Android OS system dialog.
  4. If the newly navigated page immediately calls Notification.requestPermission(), a new request is added and placed into the active requests_ vector. IsRequestInProgress() evaluates to true again.
  5. When the user eventually taps “Allow” on the lingering Android system dialog, the Java RequestDelegate callback executes and triggers the native JNI function ResolveNotificationsPermissionRequest (components/permissions/android/android_permission_util.cc).
  6. permissions::internal::ResolveNotificationsPermissionRequest verifies that IsRequestInProgress() is true and that the first request is of type NOTIFICATIONS. Crucially, it performs no origin validation to ensure the active request matches the one that originally triggered the OS prompt.
  7. The function blindly calls permission_request_manager->Accept(), which grants the permission to the current request (the new origin) and persists the setting in the HostContentSettingsMap, entirely bypassing standard origin checks and “Quiet UI” muting behaviors.

Prerequisites

  • Android device (Android 13+ where POST_NOTIFICATIONS is a runtime permission).
  • Chrome feature flag kPermissionsAndroidClapperLoud enabled.

Potential Reproduction Steps

Note: These are suggested steps based on static code analysis; our tooling agent does not have the ability to run live code.

  1. An attacker hosts two origins: https://origin-a.com and https://origin-b.com.
  2. A user visits origin-a.com and clicks a button that calls Notification.requestPermission().
  3. The Chrome Clapper Loud message UI appears. The user clicks “Allow”.
  4. The Android system “Allow Chrome to send notifications?” dialog appears.
  5. In the background, origin-a.com executes a script to navigate the main frame to https://origin-b.com (e.g., via a delayed setTimeout).
  6. Once origin-b.com loads, it immediately calls Notification.requestPermission(), placing its request at the head of the browser’s permission queue.
  7. The user, believing the prompt applies to the original action on origin-a.com, clicks “Allow” on the Android system dialog.
  8. origin-b.com is permanently granted notification permission.

Suggested Fix

The resolution callback mechanism must uniquely identify the request that triggered the OS prompt.

  • When requesting the OS-level permission in PermissionUtil.java or android_permission_util.cc, capture the requesting origin (or a unique request ID).
  • Pass this origin/ID back through the JNI callback JNI_PermissionUtil_ResolveNotificationsPermissionRequest.
  • Inside permissions::internal::ResolveNotificationsPermissionRequest, explicitly assert that permission_request_manager->Requests()[0]->requesting_origin() matches the origin that initiated the OS prompt before invoking Accept().

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


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