Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in CustomTabs
DescriptionInappropriate implementation in CustomTabs
ComponentCustomTabs
Bug ClassLogic Error
Tracker501859865
Fix commita914aeeab6a1 (chromium/src) +57/-32
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
chrome/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.java
  • chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/RealtimeEngagementSignalObserverUnitTest.java
From a914aeeab6a1204da0e59268ca285c7e2aa8cad7 Mon Sep 17 00:00:00 2001
From: Sinan Sahin <[email protected]>
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 <[email protected]>
Commit-Queue: Sinan Sahin <[email protected]>
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);
 
Loading diff…

Original Bug Report

reported by [email protected]

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.java
  • chrome/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:

  1. Server-side Redirects: didStartNavigationInPrimaryMainFrame evaluates the initial URL. If the embedder launches a CCT to a URL that redirects to a URL containing a text fragment, mSignalsPaused remains false because it was evaluated against the pre-redirect URL. The anonymous WebContentsObserver in RealtimeEngagementSignalObserver does not override didRedirectNavigation to re-evaluate the URL.
  2. 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 triggers didStartNavigationInPrimaryMainFrame, resetting mSignalsPaused to false (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.
  3. Late Callback Registration: The observer is created lazily in EngagementSignalsHandler when the embedder calls setEngagementSignalsCallback. If this call is deferred until after the text-fragment navigation has started, the observer is initialized with mSignalsPaused defaulting to false, 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.

  1. A malicious app creates a CustomTabsSession and registers an EngagementSignalsCallback.
  2. The app launches a Custom Tab to an attacker-controlled redirector URL (e.g., https://attacker.example/redirector).
  3. didStartNavigationInPrimaryMainFrame evaluates the initial URL (no fragment) and sets mSignalsPaused = false.
  4. The redirector responds with HTTP 302 to https://victim.example/page#:~:text=SECRET_TOKEN.
  5. Because didRedirectNavigation is not overridden, mSignalsPaused remains false.
  6. Chrome navigates to the victim page and Blink auto-scrolls to the text match.
  7. The user performs a small manual scroll (required to trigger a telemetry update).
  8. The app’s EngagementSignalsCallback.onGreatestScrollPercentageIncreased fires 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.

View on issue tracker