CVE-2026-78936
Overview
Background
- Custom Tabs Engagement Signals
- On Android,
RealtimeEngagementSignalObserverforwards user-engagement events from a Custom Tab, such asonVerticalScrollEventandonGreatestScrollPercentageIncreased, to the third-party app that launched the tab throughmEngagementSignalsCallback. - `mSignalsPaused`
- a flag in
RealtimeEngagementSignalObserverthat stops scroll signals from being sent while the current page was opened at a URL fragment. The browser may auto-scroll such a page, so the scroll position can reveal something about the page’s content. - Text fragments and element fragments
- A URL fragment like
#:~:text=...(a text fragment) or#target(an element id) makes the browser scroll to matching content. How far it scrolls therefore depends on whether, and where, that text or element exists on a page the launching app cannot otherwise read. - Visible vs. last committed URL; same-document navigation
getVisibleUrl()can show a pending navigation’s URL, whilegetLastCommittedUrl()is the page actually displayed. A same-document navigation, such as a fragment change orhistory.pushState, changes the URL without loading a new document or resetting its scroll state.
Root Cause Analysis
RealtimeEngagementSignalObserver is meant to stop scroll engagement signals while the displayed page was opened at a fragment, so the launching app cannot tell from scroll data whether the fragment matched. The pre-patch logic had three overlapping gaps, matching the three bug IDs the commit fixes. First, the initial pause decision in the tab-attach code (the enclosing method is not named in the hunk) used LinkToTextHelper.hasTextFragment(mWebContents.getVisibleUrl()). If a pending navigation without a fragment was visible, the signals stayed unpaused even though the committed page had one. Second, didFinishNavigationInPrimaryMainFrame recomputed mSignalsPaused on every committed navigation, including same-document ones. A same-document navigation to a URL without a text fragment therefore un-paused signals on a page that had already scrolled to a fragment. Third, only text fragments counted, so element fragments like #target, which also auto-scroll, were never paused.
The fix makes the check stricter in all three ways. It reads getLastCommittedUrl() at attach time, ignores same-document navigations (!navigationHandle.isSameDocument()), and pauses whenever the URL has any ref (!getRef().isEmpty()), not only a :~:text= directive.
Attack Path
- App launches a Custom Tab with engagement signals
A third-party Android app that receives Custom Tabs engagement signals opens a cross-site URL with a fragment, such as
#:~:text=...or#someid, that probes for content on the target page (inferred attacker model). - Browser auto-scrolls on match If the text or element exists, Chrome scrolls the page to it. The scroll position now depends on content the app cannot read directly.
- Pause bypassed
Before the fix, signals stayed active in three cases: the fragment was an element id rather than a text fragment, the visible URL at attach time differed from the committed URL, or a later same-document navigation (for example, one triggered by the page) cleared
mSignalsPaused. - Observe scroll signals
When the user scrolls, the app gets
onVerticalScrollEventandonGreatestScrollPercentageIncreased. From their values it may infer whether the fragment matched, and so learn something about cross-site page content (inferred).
Impact Assessment
(inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/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.javachrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserverUnitTest.java
Audit Directions
- Visible vs. committed URL in security decisionsSearch Android
customtabs/and other embedder-facing observers forgetVisibleUrl()used to make privacy or security decisions. A pending navigation’s URL can differ from the document whose data is being exposed. - State reset on same-document navigationReview
didFinishNavigation*handlers that reset protective flags without checkingisSameDocument(). Pages control same-document navigations through fragments andhistory.pushState, which can wipe the protective state. - Scroll/layout side channels to embeddersCheck other APIs that expose scroll depth, viewport or timing to a cross-origin embedder or launching app, such as Custom Tabs callbacks,
PostMessagechannels and partial custom tab resize events. Confirm they are suppressed for every scroll-to-fragment mechanism (:~:text=, element ids, Scroll To Text) and not just one.
Patch
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 @@
private Tab createNewTab() {
Tab tab = mock(Tab.class);
var webContents = mock(MockWebContents.class);
- when(webContents.getVisibleUrl()).thenReturn(GURL.emptyGURL());
+ when(webContents.getLastCommittedUrl()).thenReturn(GURL.emptyGURL());
when(tab.getWebContents()).thenReturn(webContents);
return tab;
}
Original Bug Report
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.javachrome/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:
- 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, andmSignalsPausedis set totrue. - The embedder dispatches a second
CustomTabsIntent(reusing the same session token) targeting an attacker-controlled slow URL (e.g.,https://attacker.example/slowwithout a fragment). This creates a browser-initiated pending entry, causinggetVisibleUrl()to return the attacker-controlled URL. - While the navigation is pending (e.g., held up by the attacker’s server), the embedder calls
CustomTabsSession.setEngagementSignalsCallback()again. - On the UI thread,
EngagementSignalsHandlerdestroys the existing observer and creates a newRealtimeEngagementSignalObserver. The new observer initializes and reads the pending attacker-controlled URL, incorrectly settingmSignalsPaused = false. - 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. - When the user performs a scroll gesture on the active victim page,
notifyGreatestScrollPercentageIncreaseddelivers 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.