CVE-2026-78949
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 bypass of CCT EngagementSignals privacy guard via same-document navigations
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: An asymmetry in RealtimeEngagementSignalObserver allows same-document navigations to clear the text-fragment privacy guard (mSignalsPaused) without resetting the scroll state baseline. This potentially allows a malicious Android app using the Custom Tabs Engagement Signals API to exploit a scroll-position oracle (XS-Leak) and determine whether and where an attacker-specified text fragment appears on a cross-origin page.
Affected files:
chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserver.java
Estimated timestamp from git blame: 2026-04-29
Description
In Chrome for Android, the Custom Tabs Engagement Signals API provides scroll percentage updates and other engagement signals to an embedder application. To prevent a cross-origin text-fragment scroll-position leakage (an XS-Leak where an app can detect if a specific :~:text= fragment matches and causes an auto-scroll on a target site), Chrome employs a privacy guard (mSignalsPaused) that pauses signals when a text fragment is present in the URL.
However, a potential vulnerability exists in RealtimeEngagementSignalObserver.java because the didFinishNavigationInPrimaryMainFrame handler unconditionally re-evaluates and clears mSignalsPaused when any primary main frame navigation commits, without checking if the navigation is a same-document navigation:
@Override
public void didFinishNavigationInPrimaryMainFrame(
NavigationHandle navigationHandle) {
if (navigationHandle.hasCommitted()) {
mSignalsPaused =
LinkToTextHelper.hasTextFragment(navigationHandle.getUrl());
}
}
By contrast, the sibling handler navigationEntryCommitted does check and exclude same-document navigations:
@Override
public void navigationEntryCommitted(LoadCommittedDetails details) {
if (details.isMainFrame() && !details.isSameDocument()) {
assumeNonNull(mScrollState).resetMaxScrollPercentage();
}
}
This asymmetry creates a potential security bypass:
- Initial Load: When a page is first loaded via Custom Tabs with a
:~:text=directive, the privacy guard is correctly engaged (mSignalsPaused = true) because the text fragment is present in the initial URL. The browser processes the text fragment and auto-scrolls the viewport to the target text fragment (if present). - Same-Document Navigation: If the page subsequently performs a same-document navigation (such as calling
history.pushState/history.replaceStateor updatinglocation.hash— which is common in Single Page Applications or can be forced by the launching application via fragment-only navigations), a primary main frame navigation commits withhasCommitted() == trueandisSameDocument() == true. - Guard Cleared: Because the fragment directive
#:~:text=is stripped from the URL of same-document navigations,LinkToTextHelper.hasTextFragment()returnsfalse, which setsmSignalsPaused = falseand clears the privacy guard. - State Preserved: Crucially, because the navigation is same-document,
navigationEntryCommitteddoes not reset the scroll state baseline (resetMaxScrollPercentage()is not called). The previous auto-scroll position is preserved. - Leaking Scroll Position: When the user performs a single scroll gesture, the observer records the absolute scroll offset (which includes the text-fragment auto-scroll offset) and transmits the scroll percentage to the third-party app, leaking whether the target text was found on the page.
Potential Steps to Reproduce
Note: These are potential steps that have not been verified via a live proof-of-concept run.
- Build an Android app that binds to Chrome’s
CustomTabsServiceand registers anEngagementSignalsCallbackviasetEngagementSignalsCallback(). - Ensure the device has Chrome’s ‘Help improve Chrome’s features and performance’ (UMA) toggle enabled (the default setting).
- Launch a Custom Tab to a page containing a text-fragment directive (e.g.,
https://example.com/page#:~:text=SECRETSTRING), where the target page performs a same-document navigation (such ashistory.replaceStateorhistory.pushState) after loading, or initiate a same-document navigation to a different fragment (e.g.,https://example.com/page#done) from the app. - Wait for the navigation to commit, which silently disables the
mSignalsPausedguard. - Perform a single scroll gesture on the page.
- Observe that the
onGreatestScrollPercentageIncreasedcallback fires with a non-zero percentage if the text fragment matched and caused an auto-scroll, potentially leaking the presence and approximate vertical location of the secret text.
Suggested Fix
Prevent same-document navigations from clearing the mSignalsPaused guard by adding a !navigationHandle.isSameDocument() check:
@Override
public void didFinishNavigationInPrimaryMainFrame(
NavigationHandle navigationHandle) {
if (navigationHandle.hasCommitted() && !navigationHandle.isSameDocument()) {
mSignalsPaused =
LinkToTextHelper.hasTextFragment(navigationHandle.getUrl());
}
}
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.