CVE-2026-79046
Overview
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 |
Files Changed
chrome/android/junit/src/org/chromium/chrome/browser/page_info/PageInfoPermissionsControllerTest.javachrome/browser/permissions/permission_blocked_message_delegate_android.ccchrome/browser/permissions/permission_blocked_message_delegate_android.hchrome/browser/permissions/permission_blocked_message_delegate_android_unittest.cc
Patch
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"
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.