Medium watch CVSS 5.5 chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
5.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in CustomTabs
DescriptionInformation leak in CustomTabs
ComponentCustomTabs
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker517772510
Fix commitb22685a26a92 (chromium/src) +126/-9
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-29 (per vendor advisory)
Fix landed2026-07-13 (public git)
Disclosed2026-08-25
Patch gap43 days — fix public in source before the advisory

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 via mSignalsPaused whether 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.

Key insight
The mistake was deriving the “pause engagement signals” decision from the potentially-pending visible URL and recomputing it on same-document navigations, letting scroll signals leak for pages that actually contain a URL fragment; the fix keys the decision on the last committed URL’s fragment and ignores same-document navigations.

Attack Path

  1. Open in Custom Tab A page is loaded in a Custom Tab whose embedding app subscribes to realtime engagement (scroll) signals.
  2. 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.
  3. 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 clears mSignalsPaused.
  4. Leak scroll engagement With mSignalsPaused wrongly false, Chrome forwards onVerticalScrollEvent/onGreatestScrollPercentageIncreased to the embedder, disclosing scroll behavior on a page that had a text fragment. (inferred as the information leaked)

Impact Assessment

The embedding Custom Tabs application can receive scroll/engagement signals for pages that carry a text fragment, when those signals were meant to be withheld — an information leak in Custom Tabs as the advisory classifies it. The precondition is a Custom Tab session with an app subscribed to engagement signals and a navigation to a fragment-bearing URL (via pending-URL desync or same-document navigation). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 5.5."

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

Audit Directions

  • Visible vs committed URL
    Audit privacy/security decisions that read getVisibleUrl(); a pending navigation can desync it from the rendered page, so getLastCommittedUrl() is usually correct.
  • Same-document navigation handling
    Review 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 checks
    Look 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.
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;
     }
Loading diff…

Original Bug Report

reported by vm...@google.com

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

  1. A malicious Android app registers a callback implementing EngagementSignalsCallback via CustomTabsConnection.setEngagementSignalsCallback().
  2. 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).
  3. 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.
  4. When the user performs a minor scroll gesture, onScrollOffsetOrExtentChanged() in RealtimeEngagementSignalObserver.java is triggered and reads the absolute vertical scroll offset (which includes the programmatic auto-scroll offset).
  5. The computed scroll percentage (rounded to multiples of 5% via ScrollState.onScrollEnded()) is reported to the host app’s callback via notifyGreatestScrollPercentageIncreased().
  6. 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.

View on issue tracker