CVE-2026-79146
Overview
Background
- Custom Tabs engagement signals
- a Custom Tabs API surface where Chrome reports scroll/engagement events (
onVerticalScrollEvent,onGreatestScrollPercentageIncreased) to the embedding app. - Text Fragment
- a URL fragment directive (
#:~:text=...) that scrolls to and highlights matching page text; scroll signals are paused when present to avoid leaking that a specific fragment was found. - `RealtimeEngagementSignalObserver`
- the Android class (in
RealtimeEngagementSignalObserver.java) that decides viamSignalsPausedwhether to forward scroll signals to the embedder. - `getVisibleUrl()` vs `getLastCommittedUrl()`
- the former can reflect a pending (not-yet-committed) navigation, while the latter reflects the URL of the page actually loaded and rendered.
Root Cause Analysis
RealtimeEngagementSignalObserver set mSignalsPaused by calling LinkToTextHelper.hasTextFragment(mWebContents.getVisibleUrl()) on attach and hasTextFragment(navigationHandle.getUrl()) on navigation. Two flaws let engagement signals resume when they should stay paused: getVisibleUrl() can return a pending navigation’s URL that lacks a text fragment even though the committed page still has one, and the navigation handler recomputed mSignalsPaused on every committed navigation including same-document navigations, so a same-document navigation to a fragment-less URL could clear the pause while the fragment-bearing document was still displayed.
The fix pauses based on the committed page’s fragment — !mWebContents.getLastCommittedUrl().getRef().isEmpty() on attach — and only recomputes on committed, non-same-document navigations (hasCommitted() && !navigationHandle.isSameDocument()). It also broadens the pause from text-fragment-only to any URL fragment (getRef() non-empty), so scroll signals are not emitted for pages navigated to a fragment/anchor.
Attack Path
- Open in Custom Tab A page is loaded in a Custom Tab whose embedding app subscribes to realtime engagement (scroll) signals.
- Land on a fragment page The user navigates to a URL bearing a text fragment (or element anchor), so the committed page should keep engagement signals paused.
- Desynchronize the URL used for the check
Either a pending navigation makes
getVisibleUrl()return a fragment-less URL while the committed page still has the fragment, or a same-document navigation to a fragment-less URL re-runs the old handler and clearsmSignalsPaused. - Leak scroll engagement
With
mSignalsPausedwronglyfalse, Chrome forwardsonVerticalScrollEvent/onGreatestScrollPercentageIncreasedto the embedder, disclosing scroll behavior on a page that had a text fragment. (inferred as the information leaked)
Impact Assessment
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 URLAudit privacy/security decisions that read
getVisibleUrl(); a pending navigation can desync it from the rendered page, sogetLastCommittedUrl()is usually correct. - Same-document navigation handlingReview navigation observers that recompute security/privacy state on every commit without checking
isSameDocument(), since fragment-only navigations can wrongly reset that state. - Narrow vs broad fragment checksLook for logic keyed on a specific fragment form (text fragments) where any fragment could equally leak or matter; compare against the generalized
getRef().isEmpty()approach used here.
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 XS-Leak in Chrome Custom Tabs via RealtimeEngagementSignalObserver scroll events
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 cross-site information leak (XS-Leak) exists in Chrome Custom Tabs on Android due to how RealtimeEngagementSignalObserver tracks viewport scroll states. While text fragments are paused to prevent information disclosure, standard element-ID fragments are not blocked, allowing an embedder application to observe whether specific element IDs exist on a cross-origin page. This occurs because programmatic auto-scrolling is triggered on load, and any subsequent user scroll leaks the resulting scroll percentage to the host app.
Affected files:
chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.javachrome/browser/share/android/java/src/org/chromium/chrome/browser/share/link_to_text/LinkToTextHelper.java
Estimated timestamp from git blame: 2023-06-06
Summary
We have identified a potential cross-site information leak (XS-Leak) in Chrome Custom Tabs’ RealtimeEngagementSignalObserver where scroll-based engagement signals are not paused during navigation using standard element-ID or named-anchor fragment identifiers (e.g., #target-id). By observing the resulting scroll percentage reported through the Custom Tabs service, a host application can potentially deduce whether specific elements exist on a cross-origin website loaded with the user’s session cookies.
Root Cause Analysis
The mitigation to suppress scroll-based engagement signals on pages with initial scroll offsets is implemented in chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java via the mSignalsPaused flag:
if (mWebContents != null) {
mSignalsPaused = LinkToTextHelper.hasTextFragment(mWebContents.getVisibleUrl());
}
And inside didFinishNavigationInPrimaryMainFrame():
@Override
public void didFinishNavigationInPrimaryMainFrame(NavigationHandle navigationHandle) {
if (navigationHandle.hasCommitted()) {
mSignalsPaused = LinkToTextHelper.hasTextFragment(navigationHandle.getUrl());
}
}
LinkToTextHelper.hasTextFragment() (located in chrome/browser/share/android/java/src/org/chromium/chrome/browser/share/link_to_text/LinkToTextHelper.java) only checks for text fragments containing :~:text=:
public static boolean hasTextFragment(GURL url) {
Uri uri = Uri.parse(url.getSpec());
String fragment = uri.getEncodedFragment();
return (fragment != null) && fragment.contains(TEXT_FRAGMENT_PREFIX);
}
However, standard fragment identifiers also trigger programmatic scroll behavior via Blink’s ElementFragmentAnchor on cross-document loads to bring the matched element into view. Because mSignalsPaused remains false for standard fragment identifiers, the absolute viewport vertical scroll offset (which includes the programmatic scroll) is updated in mScrollState.onScrollUpdate(). The resulting computed scroll percentage is then reported to the host application’s callback when any subsequent user gesture is completed.
Potential Attack Scenario
(Note: These are potential steps; our tooling cannot currently execute live proof-of-concept code to verify)
- A malicious Android app registers a callback implementing
EngagementSignalsCallbackviaCustomTabsConnection.setEngagementSignalsCallback(). - The app launches a Custom Tab pointing to a target cross-origin site with a standard element fragment corresponding to a private user state (e.g.,
https://victim.example/account#admin-panel). - If the element exists (e.g., the user is logged in as an administrator), Blink programmatically scrolls the viewport on page load to bring the element into view.
- When the user performs a minor scroll gesture,
onScrollOffsetOrExtentChanged()inRealtimeEngagementSignalObserver.javais triggered and reads the absolute vertical scroll offset (which includes the programmatic auto-scroll offset). - The computed scroll percentage (rounded to multiples of 5% via
ScrollState.onScrollEnded()) is reported to the host app’s callback vianotifyGreatestScrollPercentageIncreased(). - Based on whether a non-zero scroll percentage is received, the app infers the presence of the element, leaking sensitive cross-origin user states.
Suggested Remediation
Modify RealtimeEngagementSignalObserver.java to pause engagement signals for any committed URL that contains any fragment identifier (which can trigger programmatic scrolling on load), rather than restricting the check solely to text fragments. For example, check if url.hasRef() is true, and if so, set mSignalsPaused to true to ensure that no programmatic-scroll data can leak cross-origin.
Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379
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.