CVE-2026-79046
Overview
Background
- `PermissionRequestManager`
- The per-
WebContentsqueue of pending permission requests.Requests()[0]is the request currently being shown, and each request carries arequesting_origin(). - Android OS notification prompt
- On Android, granting web notifications can also require the system
POST_NOTIFICATIONSpermission. Chrome shows the OS dialog asynchronously and resolves the pending Chrome request when the OS dialog returns, viaPermissionUtil→ 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.
Attack Path
- 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.
- 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 samePermissionRequestManager(inferred: e.g. an attacker-controlled page that requests notifications right after navigation). - Stale response applied
Pre-patch, when the OS dialog returned,
ResolveNotificationsPermissionRequestchecked only the request type and accepted (orDismissNotificationsPermissionRequestdismissed) the front request, whatever its origin. - 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
GURLchrome/browser/permissions/permission_blocked_message_delegate_android.h |
modified | |
WebContentschrome/browser/permissions/permission_blocked_message_delegate_android.h |
modified | |
ifcomponents/page_info/android/java/src/org/chromium/components/page_info/PageInfoPermissionsController.java |
modified | |
ifcomponents/permissions/android/android_permission_util.cc |
modified | |
switchcomponents/permissions/android/android_permission_util.cc |
modified | |
GURLcomponents/permissions/android/android_permission_util.h |
modified | |
WebContentscomponents/permissions/android/android_permission_util.h |
modified |
Files Changed
components/permissions/android/android_permission_util.ccchrome/browser/permissions/permission_blocked_message_delegate_android.cccomponents/permissions/android/BUILD.gnchrome/browser/permissions/permission_blocked_message_delegate_android.hcomponents/permissions/android/android_permission_util.hcomponents/page_info/android/java/src/org/chromium/components/page_info/PageInfoPermissionsController.javachrome/android/junit/src/org/chromium/chrome/browser/page_info/PageInfoPermissionsControllerTest.javacomponents/permissions/android/android_permission_util_unittest.cccomponents/permissions/android/java/src/org/chromium/components/permissions/PermissionUtil.javachrome/browser/permissions/permission_blocked_message_delegate_android_unittest.cc
Audit Directions
- Async UI decisions applied to "current" stateLook 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 identityThe 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 mismatchIn
PageInfoPermissionsController, afalsefromresolveNotificationsPermissionRequest(now also returned on origin mismatch) falls back tosetContentSettingDefaultScope(... ALLOW). Check that this fallback always targets the origin the user actually consented for and cannot be steered by navigation.
Patch
From 2c8512aeee6d51a3f606a09998dfca190959f1cd Mon Sep 17 00:00:00 2001 From: Jochen Eisinger <[email protected]> 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 <[email protected]> Reviewed-by: Balazs Engedy <[email protected]> Reviewed-by: Antonio Sartori <[email protected]> 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)
Regression Test / PoC
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);
Original Bug Report
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.ccchrome/browser/permissions/permission_blocked_message_delegate_android.cccomponents/permissions/android/java/src/org/chromium/components/permissions/PermissionUtil.javacomponents/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
- 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 triggersResolveWithOSPrompt. - This calls into Java (
PermissionUtil.java:requestAndResolveNotificationsPermissionRequest), creating an anonymousRequestDelegatethat requests thePOST_NOTIFICATIONSpermission from Android. This delegate captures theWebContentsbut fails to record the origin or a unique request ID associated with the prompt. - 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 callsCleanUpRequests, which clears the active and pending request queues but cannot programmatically dismiss the Android OS system dialog. - If the newly navigated page immediately calls
Notification.requestPermission(), a new request is added and placed into the activerequests_vector.IsRequestInProgress()evaluates totrueagain. - When the user eventually taps “Allow” on the lingering Android system dialog, the Java
RequestDelegatecallback executes and triggers the native JNI functionResolveNotificationsPermissionRequest(components/permissions/android/android_permission_util.cc). permissions::internal::ResolveNotificationsPermissionRequestverifies thatIsRequestInProgress()is true and that the first request is of typeNOTIFICATIONS. Crucially, it performs no origin validation to ensure the active request matches the one that originally triggered the OS prompt.- The function blindly calls
permission_request_manager->Accept(), which grants the permission to the current request (the new origin) and persists the setting in theHostContentSettingsMap, entirely bypassing standard origin checks and “Quiet UI” muting behaviors.
Prerequisites
- Android device (Android 13+ where
POST_NOTIFICATIONSis a runtime permission). - Chrome feature flag
kPermissionsAndroidClapperLoudenabled.
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.
- An attacker hosts two origins:
https://origin-a.comandhttps://origin-b.com. - A user visits
origin-a.comand clicks a button that callsNotification.requestPermission(). - The Chrome Clapper Loud message UI appears. The user clicks “Allow”.
- The Android system “Allow Chrome to send notifications?” dialog appears.
- In the background,
origin-a.comexecutes a script to navigate the main frame tohttps://origin-b.com(e.g., via a delayedsetTimeout). - Once
origin-b.comloads, it immediately callsNotification.requestPermission(), placing its request at the head of the browser’s permission queue. - The user, believing the prompt applies to the original action on
origin-a.com, clicks “Allow” on the Android system dialog. origin-b.comis 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.javaorandroid_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 thatpermission_request_manager->Requests()[0]->requesting_origin()matches the origin that initiated the OS prompt before invokingAccept().
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.