Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Messages
DescriptionInappropriate implementation in Messages
ComponentMessages
Bug ClassLogic Error
Tracker514063859
Fix commite134c430fe74 (chromium/src) +120/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
if
components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeController.java
modified

Files Changed

  • components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeController.java
  • components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeControllerTest.java
  • components/messages/android/java/src/org/chromium/components/messages/MessageFeatureList.java
  • components/messages/android/messages_feature.cc
From e134c430fe74d0ebc84d4e06445a0aa3f40327a4 Mon Sep 17 00:00:00 2001
From: Lijin Shen <lazzzis@google.com>
Date: Tue, 02 Jun 2026 09:21:48 -0700
Subject: [PATCH] Dismiss messages on primary page changed

Currently, the message is not updated on page redirect. Sometimes,
redirect will bring page to another origin, which might make messages
show on a different origin.

This CL makes the message still survive same-origin reload but dismissed
on cross-origin reload.

Bug: 514063859
Change-Id: I68f2b387ea1371a090c3cde49f5aae012a89d72d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7882298
Commit-Queue: Lijin Shen <lazzzis@google.com>
Reviewed-by: Theresa Wellington <twellington@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1640227}
---

diff --git a/components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeController.java b/components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeController.java
index 9bd3c5c..03be4b7 100644
--- a/components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeController.java
+++ b/components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeController.java
@@ -99,6 +99,9 @@
             mDelegate = delegate;
             mScopeKey = scopeKey;
             WebContents webContents = scopeKey.webContents;
+            if (webContents != null) {
+                mLastVisitedUrl = webContents.getLastCommittedUrl();
+            }
             int changeType =
                     webContents != null && webContents.getVisibility() == Visibility.VISIBLE
                             ? ChangeType.ACTIVE
@@ -125,9 +128,19 @@
                 return;
             }
 
-            if (navigationHandle.isSameDocument()
-                    || !navigationHandle.hasCommitted()
-                    || navigationHandle.isReload()) {
+            if (navigationHandle.isSameDocument() || !navigationHandle.hasCommitted()) {
+                return;
+            }
+
+            if (navigationHandle.isReload()) {
+                if (MessageFeatureList.dismissNavigationMessagesOnPrimaryPageChanged()) {
+                    if (mLastVisitedUrl != null
+                            && !originEquals(mLastVisitedUrl, navigationHandle.getUrl())) {
+                        destroy();
+                        return;
+                    }
+                    mLastVisitedUrl = navigationHandle.getUrl();
+                }
                 return;
             }
 
diff --git a/components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeControllerTest.java b/components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeControllerTest.java
index 631a47f8..b780a05 100644
--- a/components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeControllerTest.java
+++ b/components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeControllerTest.java
@@ -20,6 +20,8 @@
 import org.robolectric.annotation.Config;
 
 import org.chromium.base.test.BaseRobolectricTestRunner;
+import org.chromium.base.test.util.Features.DisableFeatures;
+import org.chromium.base.test.util.Features.EnableFeatures;
 import org.chromium.components.messages.MessageScopeChange.ChangeType;
 import org.chromium.content_public.browser.NavigationHandle;
 import org.chromium.content_public.browser.Page;
@@ -32,6 +34,7 @@
 /** A test for {@link ScopeChangeController}. */
 @RunWith(BaseRobolectricTestRunner.class)
 @Config(manifest = Config.NONE)
+@EnableFeatures(MessageFeatureList.DISMISS_NAVIGATION_MESSAGES_ON_PRIMARY_PAGE_CHANGED)
 public class ScopeChangeControllerTest {
     private static final boolean IS_SAME_DOCUMENT = true;
     private static final boolean IS_RELOAD = true;
@@ -329,6 +332,94 @@
                 captor.getValue().changeType);
     }
 
+    @Test
+    @SmallTest
+    public void testRedirectedReload_DismissEnabled() {
+        ScopeChangeController.Delegate delegate =
+                Mockito.mock(ScopeChangeController.Delegate.class);
+        ScopeChangeController controller = new ScopeChangeController(delegate);
+
+        MockWebContents webContents = mock(MockWebContents.class);
+        GURL url1 = JUnitTestGURLs.GOOGLE_URL;
+        Mockito.when(webContents.getLastCommittedUrl()).thenReturn(url1);
+
+        ScopeKey key = new ScopeKey(MessageScopeType.NAVIGATION, webContents);
+        controller.firstMessageEnqueued(key);
+
+        final ArgumentCaptor<WebContentsObserver> runnableCaptor =
+                ArgumentCaptor.forClass(WebContentsObserver.class);
+        verify(webContents).addObserver(runnableCaptor.capture());
+
+        WebContentsObserver observer = runnableCaptor.getValue();
+
+        observer.didFinishNavigationInPrimaryMainFrame(
+                createNavigationHandleWithUrl(
+                        !IS_SAME_DOCUMENT, IS_RELOAD, DID_COMMIT, JUnitTestGURLs.EXAMPLE_URL));
+
+        ArgumentCaptor<MessageScopeChange> captor =
+                ArgumentCaptor.forClass(MessageScopeChange.class);
+        verify(delegate, times(2)).onScopeChange(captor.capture());
+        Assert.assertEquals(
+                "Scope type should be destroy when reload redirected to different origin",
+                ChangeType.DESTROY,
+                captor.getValue().changeType);
+    }
+
+    @Test
+    @SmallTest
+    @DisableFeatures(MessageFeatureList.DISMISS_NAVIGATION_MESSAGES_ON_PRIMARY_PAGE_CHANGED)
+    public void testRedirectedReload_DismissDisabled() {
+        ScopeChangeController.Delegate delegate =
+                Mockito.mock(ScopeChangeController.Delegate.class);
+        ScopeChangeController controller = new ScopeChangeController(delegate);
+
+        MockWebContents webContents = mock(MockWebContents.class);
+        GURL url1 = JUnitTestGURLs.GOOGLE_URL;
+        Mockito.when(webContents.getLastCommittedUrl()).thenReturn(url1);
+
+        ScopeKey key = new ScopeKey(MessageScopeType.NAVIGATION, webContents);
+        controller.firstMessageEnqueued(key);
+
+        final ArgumentCaptor<WebContentsObserver> runnableCaptor =
+                ArgumentCaptor.forClass(WebContentsObserver.class);
+        verify(webContents).addObserver(runnableCaptor.capture());
+
+        WebContentsObserver observer = runnableCaptor.getValue();
+
+        observer.didFinishNavigationInPrimaryMainFrame(
+                createNavigationHandleWithUrl(
+                        !IS_SAME_DOCUMENT, IS_RELOAD, DID_COMMIT, JUnitTestGURLs.EXAMPLE_URL));
+
+        verify(delegate, times(1)).onScopeChange(any());
+    }
+
+    @Test
+    @SmallTest
+    public void testRedirectedReload_SameOrigin() {
+        ScopeChangeController.Delegate delegate =
+                Mockito.mock(ScopeChangeController.Delegate.class);
+        ScopeChangeController controller = new ScopeChangeController(delegate);
+
+        MockWebContents webContents = mock(MockWebContents.class);
+        GURL url1 = JUnitTestGURLs.GOOGLE_URL;
+        Mockito.when(webContents.getLastCommittedUrl()).thenReturn(url1);
+
+        ScopeKey key = new ScopeKey(MessageScopeType.NAVIGATION, webContents);
+        controller.firstMessageEnqueued(key);
+
+        final ArgumentCaptor<WebContentsObserver> runnableCaptor =
+                ArgumentCaptor.forClass(WebContentsObserver.class);
+        verify(webContents).addObserver(runnableCaptor.capture());
+
+        WebContentsObserver observer = runnableCaptor.getValue();
+
+        observer.didFinishNavigationInPrimaryMainFrame(
+                createNavigationHandleWithUrl(
+                        !IS_SAME_DOCUMENT, IS_RELOAD, DID_COMMIT, JUnitTestGURLs.GOOGLE_URL_DOG));
+
+        verify(delegate, times(1)).onScopeChange(any());
+    }
+
     private NavigationHandle createNavigationHandle(
             boolean isSameDocument, boolean isReload, boolean didCommit) {
         return createNavigationHandleWithUrl(isSameDocument, isReload, didCommit, null);
diff --git a/components/messages/android/java/src/org/chromium/components/messages/MessageFeatureList.java b/components/messages/android/java/src/org/chromium/components/messages/MessageFeatureList.java
index 209f8d1f..7f315bd1 100644
--- a/components/messages/android/java/src/org/chromium/components/messages/MessageFeatureList.java
+++ b/components/messages/android/java/src/org/chromium/components/messages/MessageFeatureList.java
@@ -18,6 +18,8 @@
             "MessagesForAndroidFullyVisibleCallback";
     public static final String MESSAGES_ANDROID_EXTRA_HISTOGRAMS = "MessagesAndroidExtraHistograms";
     public static final String MESSAGES_CLOSE_BUTTON = "MessagesCloseButton";
+    public static final String DISMISS_NAVIGATION_MESSAGES_ON_PRIMARY_PAGE_CHANGED =
+            "DismissNavigationMessagesOnPrimaryPageChanged";
 
     public static boolean isFullyVisibleCallbackEnabled() {
         return MessageFeatureMap.isEnabled(MESSAGES_FOR_ANDROID_FULLY_VISIBLE_CALLBACK);
@@ -30,4 +32,8 @@
     public static boolean isCloseButtonEnabled() {
         return MessageFeatureMap.isEnabled(MESSAGES_CLOSE_BUTTON);
     }
+
+    public static boolean dismissNavigationMessagesOnPrimaryPageChanged() {
+        return MessageFeatureMap.isEnabled(DISMISS_NAVIGATION_MESSAGES_ON_PRIMARY_PAGE_CHANGED);
+    }
 }
diff --git a/components/messages/android/messages_feature.cc b/components/messages/android/messages_feature.cc
index ff75fa9..a94fc43 100644
--- a/components/messages/android/messages_feature.cc
+++ b/components/messages/android/messages_feature.cc
@@ -17,7 +17,7 @@
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential origin confusion in 'Pop-ups blocked' message via cross-origin reload-redirect

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A logic error in the Android Messages ScopeChangeController allows navigation-scoped messages to persist across cross-origin navigations if they are initiated as a reload. This occurs because the reload status is preserved across server-side redirects, preventing the message from being dismissed when the origin changes. An attacker could exploit this to trick users into granting persistent permissions to an attacker origin while viewing a victim site.

Affected files:

  • components/messages/android/internal/java/src/org/chromium/components/messages/ScopeChangeController.java
  • components/blocked_content/android/popup_blocked_message_delegate.cc
  • components/blocked_content/popup_blocker_tab_helper.cc

Estimated timestamp from git blame: 2022-09-27

Description

The ScopeChangeController in Android Messages is responsible for dismissing messages when a navigation occurs. Specifically, ScopeChangeController.NavigationWebContentsScopeObserver.didFinishNavigationInPrimaryMainFrame() handles navigation-dismissal for MessageScopeType.NAVIGATION and MessageScopeType.ORIGIN messages. However, this method contains an early-return if navigationHandle.isReload() is true, which appears to bypass origin-change checks during reloads.

In Chromium, a navigation initiated as a reload (e.g., via location.reload()) maintains its isReload status even if it encounters a server-side 302 redirect to a cross-origin destination. Because ScopeChangeController relies on this flag to skip dismissal, a message banner (like ‘Pop-ups blocked’) enqueued by an attacker origin can remain visible after the tab has committed a navigation to a victim origin.

This behavior can be exploited via the PopupBlockedMessageDelegate. When a popup is blocked, the delegate enqueues a NAVIGATION scoped message and caches the current origin’s URL. If the user clicks ‘Always show’ after the page has redirected and committed to a victim site, the delegate uses the cached attacker URL to grant a persistent POPUPS=ALLOW content setting via HostContentSettingsMap.

Potential Reproduction Steps

  1. On Chrome for Android, navigate to an attacker-controlled page (e.g., https://attacker.example).
  2. The page attempts to open a popup without a user gesture, triggering the ‘Pop-ups blocked’ banner.
  3. The attacker page immediately performs a location.reload(), which the server redirects to https://victim.example/ via a 302 response.
  4. The navigation commits to the victim site. Due to the isReload() check in ScopeChangeController.java, the ‘Pop-ups blocked’ message remains visible.
  5. The user sees victim.example in the omnibox and, believing the permission applies to the visible site, taps ‘Always show’.
  6. The PopupBlockedMessageDelegate uses its cached URL to grant the permission to attacker.example instead of the visible victim site.

Impact

This is a potential UI misrepresentation and origin confusion issue. An attacker could trick a user into granting persistent POPUPS=ALLOW permissions to the attacker’s origin, allowing future popups without user gestures and potentially bypassing other security interventions like framebust protections.

Suggested Fix

In ScopeChangeController.java, the didFinishNavigationInPrimaryMainFrame method should be modified to ensure that messages are dismissed if the origin has changed, even if navigationHandle.isReload() is true. The isReload() check should likely only permit skipping dismissal if the new navigation’s origin matches the origin associated with the current message scope.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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