CVE-2026-11278
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 a914aeeab6a1204da0e59268ca285c7e2aa8cad7 Mon Sep 17 00:00:00 2001
From: Sinan Sahin <sinansahin@google.com>
Date: Wed, 29 Apr 2026 11:38:08 -0700
Subject: [PATCH] [CCT][EngagementSignals] Update navigation signal used for pausing signals
Instead of `didStartNavigationInPrimaryMainFrame`, we now use
`didFinishNavigationInPrimaryMainFrame` and check
`navigationHandle.hasCommitted()`. We also initialize mSignalsPaused when
we start sending engagement signals.
Bug: 501859865
Change-Id: Ib862f7d8e2ea30d56f9acc07eb496f6076ca7af1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7800705
Reviewed-by: Jinsuk Kim <jinsukkim@chromium.org>
Commit-Queue: Sinan Sahin <sinansahin@google.com>
Cr-Commit-Position: refs/heads/main@{#1622602}
---
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 2d6a6c8..547ec81 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
@@ -217,6 +217,10 @@
mWebContents = tab.getWebContents();
mScrollState = ScrollState.from(tab);
+ if (mWebContents != null) {
+ mSignalsPaused = LinkToTextHelper.hasTextFragment(mWebContents.getVisibleUrl());
+ }
+
mGestureStateListener =
new GestureStateListener() {
@Override
@@ -288,10 +292,12 @@
}
@Override
- public void didStartNavigationInPrimaryMainFrame(
+ public void didFinishNavigationInPrimaryMainFrame(
NavigationHandle navigationHandle) {
- mSignalsPaused =
- LinkToTextHelper.hasTextFragment(navigationHandle.getUrl());
+ if (navigationHandle.hasCommitted()) {
+ mSignalsPaused =
+ LinkToTextHelper.hasTextFragment(navigationHandle.getUrl());
+ }
}
};
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 929bbca2..9b2dfb5 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
@@ -56,8 +56,10 @@
import org.chromium.content_public.browser.GestureStateListener;
import org.chromium.content_public.browser.LoadCommittedDetails;
import org.chromium.content_public.browser.NavigationHandle;
+import org.chromium.content_public.browser.Page;
import org.chromium.content_public.browser.WebContentsObserver;
import org.chromium.content_public.browser.test.mock.MockWebContents;
+import org.chromium.url.GURL;
import org.chromium.url.JUnitTestGURLs;
import java.util.List;
@@ -622,8 +624,7 @@
public void onAllTabsClosed_hadInteraction_sendsOnSessionEnded() {
initializeTabForTest();
doReturn(false).when(mTabInteractionRecorder).didGetUserInteraction();
- Tab tab = mock(Tab.class);
- doReturn(mock(MockWebContents.class)).when(tab).getWebContents();
+ Tab tab = createNewTab();
mEngagementSignalObserver.onObservingDifferentTab(tab);
doReturn(true).when(mTabInteractionRecorder).didGetUserInteraction();
// Close all tabs.
@@ -640,8 +641,7 @@
public void onAllTabsClosed_hadInteractionButIncognito_sendsOnSessionEnded() {
initializeTabForTest();
doReturn(false).when(mTabInteractionRecorder).didGetUserInteraction();
- Tab tab = mock(Tab.class);
- doReturn(mock(MockWebContents.class)).when(tab).getWebContents();
+ Tab tab = createNewTab();
// Turn on Incognito.
doReturn(true).when(tab).isIncognito();
mEngagementSignalObserver.onObservingDifferentTab(tab);
@@ -662,10 +662,9 @@
public void onAllTabsClosed_hadInteractionButUmaUploadDisabled_sendsOnSessionEnded() {
initializeTabForTest();
doReturn(false).when(mTabInteractionRecorder).didGetUserInteraction();
- Tab tab = mock(Tab.class);
+ Tab tab = createNewTab();
// Disable UMA upload.
doReturn(false).when(mPrivacyPreferencesManagerImpl).isUsageAndCrashReportingPermitted();
- doReturn(mock(MockWebContents.class)).when(tab).getWebContents();
mEngagementSignalObserver.onObservingDifferentTab(tab);
// User interacted.
doReturn(true).when(mTabInteractionRecorder).didGetUserInteraction();
@@ -684,8 +683,7 @@
public void onAllTabsClosed_hadNoInteraction_sendsOnSessionEnded() {
initializeTabForTest();
doReturn(false).when(mTabInteractionRecorder).didGetUserInteraction();
- Tab tab = mock(Tab.class);
- doReturn(mock(MockWebContents.class)).when(tab).getWebContents();
+ Tab tab = createNewTab();
mEngagementSignalObserver.onObservingDifferentTab(tab);
// Close all tabs.
mEngagementSignalObserver.onClosingStateChanged(tab, true);
@@ -701,8 +699,7 @@
initializeTabForTest();
mEngagementSignalObserver.suppressNextSessionEndedCall();
doReturn(false).when(mTabInteractionRecorder).didGetUserInteraction();
- Tab tab = mock(Tab.class);
- doReturn(mock(MockWebContents.class)).when(tab).getWebContents();
+ Tab tab = createNewTab();
mEngagementSignalObserver.onObservingDifferentTab(tab);
// Close all tabs.
mEngagementSignalObserver.onClosingStateChanged(tab, true);
@@ -720,8 +717,7 @@
public void onDestroyed_hadInteraction_sendsOnSessionEnded() {
initializeTabForTest();
doReturn(false).when(mTabInteractionRecorder).didGetUserInteraction();
- Tab tab = mock(Tab.class);
- doReturn(mock(MockWebContents.class)).when(tab).getWebContents();
+ Tab tab = createNewTab();
mEngagementSignalObserver.onObservingDifferentTab(tab);
// User interacted.
doReturn(true).when(mTabInteractionRecorder).didGetUserInteraction();
@@ -737,8 +733,7 @@
public void onDestroyed_hadInteractionButIncognito_sendsOnSessionEnded() {
initializeTabForTest();
doReturn(false).when(mTabInteractionRecorder).didGetUserInteraction();
- Tab tab = mock(Tab.class);
- doReturn(mock(MockWebContents.class)).when(tab).getWebContents();
+ Tab tab = createNewTab();
// Turn on Incognito.
doReturn(true).when(tab).isIncognito();
mEngagementSignalObserver.onObservingDifferentTab(tab);
@@ -756,8 +751,7 @@
public void onDestroyed_hadInteractionButUmaUploadDisabled_sendsOnSessionEnded() {
initializeTabForTest();
doReturn(false).when(mTabInteractionRecorder).didGetUserInteraction();
- Tab tab = mock(Tab.class);
- doReturn(mock(MockWebContents.class)).when(tab).getWebContents();
+ Tab tab = createNewTab();
// Disable UMA upload.
doReturn(false).when(mPrivacyPreferencesManagerImpl).isUsageAndCrashReportingPermitted();
mEngagementSignalObserver.onObservingDifferentTab(tab);
@@ -777,8 +771,7 @@
// Suspend.
mEngagementSignalObserver.suppressNextSessionEndedCall();
doReturn(false).when(mTabInteractionRecorder).didGetUserInteraction();
- Tab tab = mock(Tab.class);
- doReturn(mock(MockWebContents.class)).when(tab).getWebContents();
+ Tab tab = createNewTab();
mEngagementSignalObserver.onObservingDifferentTab(tab);
// Tab destroyed.
mEngagementSignalObserver.onDestroyed(tab);
@@ -798,10 +791,8 @@
WebContentsObserver webContentsObserver = captureWebContentsObserver();
// Navigate to a URL with text fragment.
- var navigationHandle =
- NavigationHandle.createForTesting(
- JUnitTestGURLs.TEXT_FRAGMENT_URL, false, 0, false);
- webContentsObserver.didStartNavigationInPrimaryMainFrame(navigationHandle);
+ var navigationHandle = createNavigationHandle(JUnitTestGURLs.TEXT_FRAGMENT_URL);
+ webContentsObserver.didFinishNavigationInPrimaryMainFrame(navigationHandle);
// Do a scroll.
listener.onScrollStarted(0, SCROLL_EXTENT, false);
@@ -816,9 +807,8 @@
.onGreatestScrollPercentageIncreased(anyInt(), any(Bundle.class));
// Navigate back to a URL with no text fragment.
- var navigationHandle2 =
- NavigationHandle.createForTesting(JUnitTestGURLs.HTTP_URL, false, 0, false);
- webContentsObserver.didStartNavigationInPrimaryMainFrame(navigationHandle2);
+ var navigationHandle2 = createNavigationHandle(JUnitTestGURLs.HTTP_URL);
+ webContentsObserver.didFinishNavigationInPrimaryMainFrame(navigationHandle2);
// Do a scroll.
listener.onScrollStarted(24, SCROLL_EXTENT, false);
@@ -915,8 +905,7 @@
@Test
public void collectUserInteraction_hasInteraction() {
initializeTabForTest();
- Tab tab = mock(Tab.class);
- doReturn(mock(MockWebContents.class)).when(tab).getWebContents();
+ Tab tab = createNewTab();
when(mTabInteractionRecorder.didGetUserInteraction()).thenReturn(true);
assertFalse(mEngagementSignalObserver.getDidGetUserInteractionForTesting());
@@ -929,8 +918,7 @@
@Test
public void collectUserInteraction_hasNoInteraction() {
initializeTabForTest();
- Tab tab = mock(Tab.class);
- doReturn(mock(MockWebContents.class)).when(tab).getWebContents();
+ Tab tab = createNewTab();
when(mTabInteractionRecorder.didGetUserInteraction()).thenReturn(false);
Original Bug Report
Potential CCT EngagementSignals privacy guard bypass allows cross-origin text fragment oracle
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 without the Chrome Security team.
Overview: The mSignalsPaused privacy guard in Custom Tabs is designed to disable scroll telemetry when a page is loaded via a text fragment, preventing cross-origin data exfiltration. However, the guard is evaluated too early in the navigation lifecycle and fails to handle redirects or non-committing navigations. This potentially allows a malicious embedder to bypass the guard and use scroll offsets as an oracle to detect the presence of text on cross-origin pages.
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: 2025-09-10
Summary
RealtimeEngagementSignalObserver in Chrome for Android’s Custom Tabs (CCT) implements a privacy guard called mSignalsPaused. This flag pauses scroll-percentage telemetry to the CCT embedder when a page is loaded via a Scroll-To-Text fragment (#:~:text=). This mitigates privacy concerns where a malicious embedder could learn if a specific piece of text exists on a cross-origin page by observing the scroll position after an auto-scroll to the text fragment.
However, the current implementation evaluates the URL too early in the navigation lifecycle, allowing the guard to be bypassed. This potentially provides a side-channel oracle for the presence and approximate vertical position of attacker-chosen strings on authenticated cross-origin pages.
Vulnerability Details
The guard is implemented by checking for text fragments during navigation start:
@Override
public void didStartNavigationInPrimaryMainFrame(NavigationHandle navigationHandle) {
mSignalsPaused = LinkToTextHelper.hasTextFragment(navigationHandle.getUrl());
}
This implementation has several logic flaws that allow the guard to be bypassed:
- Server-side Redirects:
didStartNavigationInPrimaryMainFrameevaluates the initial URL. If the embedder launches a CCT to a URL that redirects to a URL containing a text fragment,mSignalsPausedremainsfalsebecause it was evaluated against the pre-redirect URL. The anonymousWebContentsObserverinRealtimeEngagementSignalObserverdoes not overridedidRedirectNavigationto re-evaluate the URL. - Non-committing Navigations: An embedder can trigger a second navigation on the same session to a URL that returns an HTTP 204 or
Content-Disposition: attachment. This triggersdidStartNavigationInPrimaryMainFrame, resettingmSignalsPausedtofalse(assuming the second URL has no fragment). Since the navigation never commits, the user remains on the original page with the text-fragment match active, but scroll telemetry is resumed. - Late Callback Registration: The observer is created lazily in
EngagementSignalsHandlerwhen the embedder callssetEngagementSignalsCallback. If this call is deferred until after the text-fragment navigation has started, the observer is initialized withmSignalsPauseddefaulting tofalse, and it does not verify the current tab’s URL upon creation.
Once the guard is cleared, observing the reported scroll percentage (rounded to 5% granularity) allows a malicious embedder to infer if a match occurred and its approximate vertical position.
Potential Reproduction Steps
Note: These steps are suggested based on code analysis; a working proof-of-concept has not been executed.
- A malicious app creates a
CustomTabsSessionand registers anEngagementSignalsCallback. - The app launches a Custom Tab to an attacker-controlled redirector URL (e.g.,
https://attacker.example/redirector). didStartNavigationInPrimaryMainFrameevaluates the initial URL (no fragment) and setsmSignalsPaused = false.- The redirector responds with
HTTP 302tohttps://victim.example/page#:~:text=SECRET_TOKEN. - Because
didRedirectNavigationis not overridden,mSignalsPausedremainsfalse. - Chrome navigates to the victim page and Blink auto-scrolls to the text match.
- The user performs a small manual scroll (required to trigger a telemetry update).
- The app’s
EngagementSignalsCallback.onGreatestScrollPercentageIncreasedfires with a percentage reflecting the match position, bypassing the intended privacy guard.
Suggested Fix
Do not rely on didStartNavigationInPrimaryMainFrame to evaluate the privacy guard. Instead, evaluate the presence of text fragments when the navigation actually commits to the frame.
Move the evaluation to navigationEntryCommitted or didFinishNavigationInPrimaryMainFrame (checking navigationHandle.hasCommitted()), ensuring that the final, committed URL is checked, including any fragments added via redirects or client-side modifications. Additionally, when the RealtimeEngagementSignalObserver is lazily instantiated, it should immediately evaluate the WebContents’s current visible URL to initialize mSignalsPaused correctly.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.