Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactObservable discrepancy in CustomTabs
DescriptionObservable discrepancy in CustomTabs
ComponentCustomTabs
Bug ClassLogic Error
Tracker517095594
Fix commitb22685a26a92 (chromium/src) +126/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
if
chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java
modified

Files Changed

  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java
  • chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserverUnitTest.java
From b22685a26a92e6c90d171ef30cf5d04fd206b192 Mon Sep 17 00:00:00 2001
From: Mohamed Adel <adelm@google.com>
Date: Mon, 13 Jul 2026 15:31:06 -0700
Subject: [PATCH] Fix overlapping Text Fragment engagement signal bugs

TAG=agy

Bug: 517095594, 517910756, 517772510
Fixed: 517095594, 517910756, 517772510
Change-Id: Id81737d68897243b5a0772fad250a916a6ad5650
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8087069
Reviewed-by: Sinan Sahin <sinansahin@google.com>
Commit-Queue: Moe Adel <adelm@google.com>
Cr-Commit-Position: refs/heads/main@{#1661423}
---

diff --git a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java
index 547ec81..6deb245 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java
@@ -29,7 +29,6 @@
 import org.chromium.chrome.browser.customtabs.features.TabInteractionRecorder;
 import org.chromium.chrome.browser.flags.ChromeFeatureList;
 import org.chromium.chrome.browser.privacy.settings.PrivacyPreferencesManagerImpl;
-import org.chromium.chrome.browser.share.link_to_text.LinkToTextHelper;
 import org.chromium.chrome.browser.tab.Tab;
 import org.chromium.chrome.browser.tab.TabHidingType;
 import org.chromium.content_public.browser.GestureListenerManager;
@@ -218,7 +217,8 @@
         mScrollState = ScrollState.from(tab);
 
         if (mWebContents != null) {
-            mSignalsPaused = LinkToTextHelper.hasTextFragment(mWebContents.getVisibleUrl());
+            // Pause if the URL has a fragment, i.e. portion that starts with #.
+            mSignalsPaused = !mWebContents.getLastCommittedUrl().getRef().isEmpty();
         }
 
         mGestureStateListener =
@@ -294,9 +294,8 @@
                     @Override
                     public void didFinishNavigationInPrimaryMainFrame(
                             NavigationHandle navigationHandle) {
-                        if (navigationHandle.hasCommitted()) {
-                            mSignalsPaused =
-                                    LinkToTextHelper.hasTextFragment(navigationHandle.getUrl());
+                        if (navigationHandle.hasCommitted() && !navigationHandle.isSameDocument()) {
+                            mSignalsPaused = !navigationHandle.getUrl().getRef().isEmpty();
                         }
                     }
                 };
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserverUnitTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserverUnitTest.java
index 83cf2741..f065b91 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserverUnitTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserverUnitTest.java
@@ -822,6 +822,112 @@
     }
 
     @Test
+    @SuppressWarnings("DirectInvocationOnMock")
+    public void pauseSignalsWhenLastCommittedUrlHasTextFragment() {
+        Tab initialTab = env.prepareTab();
+        // The visible URL reflects a pending navigation without a text fragment, but the last
+        // committed page still has one.
+        when(env.webContents.getVisibleUrl()).thenReturn(JUnitTestGURLs.HTTP_URL);
+        when(env.webContents.getLastCommittedUrl()).thenReturn(JUnitTestGURLs.TEXT_FRAGMENT_URL);
+        doAnswer(
+                        invocation -> {
+                            CustomTabTabObserver observer = invocation.getArgument(0);
+                            initialTab.addObserver(observer);
+                            observer.onAttachedToInitialTab(initialTab);
+                            return null;
+                        })
+                .when(env.tabObserverRegistrar)
+                .registerActivityTabObserver(any());
+        mEngagementSignalObserver =
+                new RealtimeEngagementSignalObserver(
+                        env.tabObserverRegistrar,
+                        env.session.getSessionAsCustomTab(),
+                        mEngagementSignalsCallback,
+                        /* hadScrollDown= */ false);
+        env.tabProvider.setInitialTab(initialTab, TabCreationMode.DEFAULT);
+
+        GestureStateListener listener = captureGestureStateListener();
+
+        // Do a scroll.
+        listener.onScrollStarted(0, SCROLL_EXTENT, false);
+        when(mRenderCoordinatesImpl.getScrollYPixInt()).thenReturn(50);
+        listener.onScrollOffsetOrExtentChanged(50, SCROLL_EXTENT);
+        listener.onScrollEnded(50, SCROLL_EXTENT);
+        RobolectricUtil.runAllBackgroundAndUi();
+        // We shouldn't get scroll signals because the committed page has a text fragment.
+        verify(mEngagementSignalsCallback, never())
+                .onVerticalScrollEvent(anyBoolean(), any(Bundle.class));
+        verify(mEngagementSignalsCallback, never())
+                .onGreatestScrollPercentageIncreased(anyInt(), any(Bundle.class));
+    }
+
+    @Test
+    public void keepSignalsPausedAfterSameDocumentNavigation() {
+        initializeTabForTest();
+        GestureStateListener listener = captureGestureStateListener(ON_SCROLL_END);
+        WebContentsObserver webContentsObserver = captureWebContentsObserver();
+
+        // Navigate to a URL with text fragment.
+        var navigationHandle = createNavigationHandle(JUnitTestGURLs.TEXT_FRAGMENT_URL);
+        webContentsObserver.didFinishNavigationInPrimaryMainFrame(navigationHandle);
+
+        // Same-document navigation to a URL with no text fragment.
+        var navigationHandle2 =
+                createNavigationHandle(JUnitTestGURLs.HTTP_URL, /* isSameDocument= */ true);
+        webContentsObserver.didFinishNavigationInPrimaryMainFrame(navigationHandle2);
+
+        // Do a scroll.
+        listener.onScrollStarted(0, SCROLL_EXTENT, false);
+        when(mRenderCoordinatesImpl.getScrollYPixInt()).thenReturn(50);
+        listener.onScrollOffsetOrExtentChanged(50, SCROLL_EXTENT);
+        listener.onScrollEnded(50, SCROLL_EXTENT);
+        RobolectricUtil.runAllBackgroundAndUi();
+        // We shouldn't get scroll signals.
+        verify(mEngagementSignalsCallback, never())
+                .onVerticalScrollEvent(anyBoolean(), any(Bundle.class));
+        verify(mEngagementSignalsCallback, never())
+                .onGreatestScrollPercentageIncreased(anyInt(), any(Bundle.class));
+    }
+
+    @Test
+    public void pauseAndUnpauseSignalsOnPageWithFragment() {
+        initializeTabForTest();
+        GestureStateListener listener = captureGestureStateListener(ON_SCROLL_END);
+        WebContentsObserver webContentsObserver = captureWebContentsObserver();
+
+        // Navigate to a URL with an element fragment.
+        var navigationHandle = createNavigationHandle(new GURL("https://www.example.com/#target"));
+        webContentsObserver.didFinishNavigationInPrimaryMainFrame(navigationHandle);
+
+        // Do a scroll.
+        listener.onScrollStarted(0, SCROLL_EXTENT, false);
+        when(mRenderCoordinatesImpl.getScrollYPixInt()).thenReturn(24);
+        listener.onScrollOffsetOrExtentChanged(24, SCROLL_EXTENT);
+        listener.onScrollEnded(24, SCROLL_EXTENT);
+        RobolectricUtil.runAllBackgroundAndUi();
+        // We shouldn't get scroll signals.
+        verify(mEngagementSignalsCallback, never())
+                .onVerticalScrollEvent(anyBoolean(), any(Bundle.class));
+        verify(mEngagementSignalsCallback, never())
+                .onGreatestScrollPercentageIncreased(anyInt(), any(Bundle.class));
+
+        // Navigate back to a URL with no fragment.
+        var navigationHandle2 = createNavigationHandle(JUnitTestGURLs.HTTP_URL);
+        webContentsObserver.didFinishNavigationInPrimaryMainFrame(navigationHandle2);
+
+        // Do a scroll.
+        listener.onScrollStarted(24, SCROLL_EXTENT, false);
+        when(mRenderCoordinatesImpl.getScrollYPixInt()).thenReturn(50);
+        listener.onScrollOffsetOrExtentChanged(50, SCROLL_EXTENT);
+        listener.onScrollEnded(50, SCROLL_EXTENT);
+        RobolectricUtil.runAllBackgroundAndUi();
+        // We should normally get signals.
+        verify(mEngagementSignalsCallback).onVerticalScrollEvent(eq(false), any(Bundle.class));
+        verify(mEngagementSignalsCallback)
+                .onGreatestScrollPercentageIncreased(eq(50), any(Bundle.class));
+    }
+
+    @Test
     public void doesNotSendSignalsBeforeDownScroll() {
         initializeTabForTest();
         GestureStateListener listener = captureGestureStateListener(ON_SCROLL_END);
@@ -932,8 +1038,8 @@
     @SuppressWarnings("DirectInvocationOnMock")
     private void initializeTabForTest(boolean hadScrollDown) {
         Tab initialTab = env.prepareTab();
-        when(initialTab.getWebContents().getVisibleUrl()).thenReturn(GURL.emptyGURL());
-        when(env.webContents.getVisibleUrl()).thenReturn(GURL.emptyGURL());
+        when(initialTab.getWebContents().getLastCommittedUrl()).thenReturn(GURL.emptyGURL());
+        when(env.webContents.getLastCommittedUrl()).thenReturn(GURL.emptyGURL());
         doAnswer(
                         invocation -> {
                             CustomTabTabObserver observer = invocation.getArgument(0);
@@ -995,7 +1101,19 @@
     }
 
     private NavigationHandle createNavigationHandle(GURL url) {
-        var navigationHandle = NavigationHandle.createForTesting(url, false, 0, false);
+        return createNavigationHandle(url, /* isSameDocument= */ false);
+    }
+
+    private NavigationHandle createNavigationHandle(GURL url, boolean isSameDocument) {
+        var navigationHandle =
+                NavigationHandle.createForTesting(
+                        url,
+                        /* isInPrimaryMainFrame= */ true,
+                        isSameDocument,
+                        /* isRendererInitiated= */ false,
+                        /* transition= */ 0,
+                        /* hasUserGesture= */ false,
+                        /* isReload= */ false);
         navigationHandle.callDidFinishForTesting(url);
         return navigationHandle;
     }
@@ -1003,7 +1121,7 @@
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential bypass of text-fragment guard in Chrome Custom Tabs via pending URL seed

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 potential logic bypass in Chrome Custom Tabs allows a malicious embedder app to bypass the text-fragment guard meant to prevent scroll-based XS-Leaks. By triggering a pending browser-initiated navigation and re-registering the EngagementSignalsCallback, the new observer can be tricked into setting mSignalsPaused to false. This can allow the embedder to receive scroll notifications on a cross-origin text-fragment-targeted page.

Affected files:

  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java
  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/EngagementSignalsHandler.java

Estimated timestamp from git blame: 2026-04-29

Description

A potential security bypass exists in the text-fragment guard of Chrome Custom Tabs’ (CCT) engagement signals mechanism. The text-fragment guard was introduced to set mSignalsPaused = true when a page is loaded with a text fragment (e.g., #:~:text=), preventing malicious embedders from measuring scroll percentages to execute cross-origin XS-Leaks. However, this guard can potentially be bypassed by re-registering the EngagementSignalsCallback during a pending, non-committing, browser-initiated navigation.

Root Cause Analysis

In RealtimeEngagementSignalObserver.java, mSignalsPaused is initialized at observer-creation time by checking the visible URL of the WebContents:

// RealtimeEngagementSignalObserver.java
if (mWebContents != null) {
    mSignalsPaused = LinkToTextHelper.hasTextFragment(mWebContents.getVisibleUrl());
}

WebContents.getVisibleUrl() retrieves the visible URL, which corresponds to the pending entry for any new, browser-initiated navigation. If a second CustomTabsIntent is sent in the same session, it creates a browser-initiated pending entry. While that navigation is pending, getVisibleUrl() returns the pending URL instead of the currently active, committed URL.

If the embedder re-registers the EngagementSignalsCallback via setEngagementSignalsCallback over Binder while this navigation is pending, EngagementSignalsHandler destroys the old observer and instantiates a new RealtimeEngagementSignalObserver (via createEngagementSignalsObserver() in EngagementSignalsHandler.java).

During its synchronous initialization, the new observer checks getVisibleUrl(), which returns the pending URL (which does not contain a text fragment), and incorrectly sets mSignalsPaused = false.

If the pending navigation is subsequently cancelled or completed with a non-committing status (such as an HTTP 204 No Content response, a network error, or a Content-Disposition: attachment response), the navigation does not commit. The observer’s didFinishNavigationInPrimaryMainFrame method skips updating mSignalsPaused because navigationHandle.hasCommitted() is false. As a result, the incorrect mSignalsPaused = false state persists on the active page, which remains scrolled to the matched text fragment.

Potential Attack Steps

Since our tooling does not yet have the ability to run code, these are potential/suggested steps to reproduce the vulnerability:

  1. A malicious Android app acting as a Custom Tabs embedder opens a Custom Tab to a target victim URL with a text-fragment directive (e.g., https://victim.example/page#:~:text=SECRET). The page commits, auto-scrolls to the match, and mSignalsPaused is set to true.
  2. The embedder dispatches a second CustomTabsIntent (reusing the same session token) targeting an attacker-controlled slow URL (e.g., https://attacker.example/slow without a fragment). This creates a browser-initiated pending entry, causing getVisibleUrl() to return the attacker-controlled URL.
  3. While the navigation is pending (e.g., held up by the attacker’s server), the embedder calls CustomTabsSession.setEngagementSignalsCallback() again.
  4. On the UI thread, EngagementSignalsHandler destroys the existing observer and creates a new RealtimeEngagementSignalObserver. The new observer initializes and reads the pending attacker-controlled URL, incorrectly setting mSignalsPaused = false.
  5. The attacker’s server responds with an HTTP 204 (No Content). The pending navigation completes without committing. The observer retains mSignalsPaused = false, and the scroll state is not reset.
  6. When the user performs a scroll gesture on the active victim page, notifyGreatestScrollPercentageIncreased delivers the rounded scroll percentage of the text-fragment match to the embedder, leaking the position/presence of the matched text.

Suggested Fix

To resolve this issue, RealtimeEngagementSignalObserver should initialize mSignalsPaused using the last committed URL instead of the visible URL. This ensures that any pending browser-initiated navigations do not affect the text-fragment guard state of the currently active document.

In RealtimeEngagementSignalObserver.java (line 221), replace:

mSignalsPaused = LinkToTextHelper.hasTextFragment(mWebContents.getVisibleUrl());

with:

mSignalsPaused = LinkToTextHelper.hasTextFragment(mWebContents.getLastCommittedUrl());

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


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