CVE-2026-79146
Overview
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
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 @@
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.