Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Permissions
DescriptionRace condition in Permissions
ComponentPermissions
Bug ClassRace
Tracker501437087
Fix commit2c8512aeee6d (chromium/src) +161/-59
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

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

Files Changed

  • chrome/android/junit/src/org/chromium/chrome/browser/page_info/PageInfoPermissionsControllerTest.java
  • chrome/browser/permissions/permission_blocked_message_delegate_android.cc
  • chrome/browser/permissions/permission_blocked_message_delegate_android.h
  • chrome/browser/permissions/permission_blocked_message_delegate_android_unittest.cc
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/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"
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