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 929bbca22d477..a00e8206ee97e 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 @@ -791,6 +791,153 @@ public class RealtimeEngagementSignalObserverUnitTest { assertFalse(mEngagementSignalObserver.getSuspendSessionEndedForTesting()); } + // ===== POC: text-fragment privacy guard (mSignalsPaused) bypass ===== + // + // The mSignalsPaused guard (RealtimeEngagementSignalObserver.java:290-295) + // is intended to suppress scroll-percentage telemetry to the CCT embedder + // when the page was reached via #:~:text=, so the embedder cannot infer + // where on a cross-origin page the attacker-chosen substring matched. + // + // The three tests below FAIL on the current code, proving the guard is + // bypassable. Each test asserts that onGreatestScrollPercentageIncreased() + // is *never* invoked while a text-fragment-navigated page is on screen; + // the assertion fails because the embedder callback receives 60% (the + // text-fragment match position), defeating the privacy mitigation. + + @Test + public void poc_textFragmentGuardBypass_serverRedirect() { + // Vector [A]: embedder launches CCT at attacker.example/r (no fragment). + // Server 302-redirects to victim.example/page#:~:text=SECRET. The + // observer only checks navigationHandle.getUrl() at DidStartNavigation, + // which per navigation_handle.h:159 is the *pre-redirect* URL; there is + // no didRedirectNavigation override. mSignalsPaused stays false even + // though the committed page was reached via a text fragment. + initializeTabForTest(); + GestureStateListener listener = captureGestureStateListener(ON_SCROLL_END); + WebContentsObserver webContentsObserver = captureWebContentsObserver(); + + // 1) Embedder-initiated nav starts at the *pre-redirect* URL (no + // fragment). This is the only DidStartNavigation that fires. + var startHandle = + NavigationHandle.createForTesting(JUnitTestGURLs.URL_1, false, 0, false); + webContentsObserver.didStartNavigationInPrimaryMainFrame(startHandle); + + // 2) Server redirects to victim#:~:text=selector. WebContentsObserver + // fires DidRedirectNavigation, which this observer does NOT + // override -> mSignalsPaused is never re-evaluated. + + // 3) Navigation commits at the text-fragment URL. + LoadCommittedDetails commit = + new LoadCommittedDetails( + 0, + JUnitTestGURLs.TEXT_FRAGMENT_URL, + false, + /* isSameDocument= */ false, + /* isMainFrame= */ true, + 200); + webContentsObserver.navigationEntryCommitted(commit); + + // 4) Chrome auto-scrolls the page to the text-fragment match (60%). + // User then performs one small *down*-scroll (60 -> 63). + listener.onScrollStarted(60, SCROLL_EXTENT, /* isDirectionUp= */ false); + when(mRenderCoordinatesImpl.getScrollYPixInt()).thenReturn(63); + listener.onScrollOffsetOrExtentChanged(63, SCROLL_EXTENT); + listener.onScrollEnded(63, SCROLL_EXTENT); + RobolectricUtil.runAllBackgroundAndUi(); + + // EXPECTED (privacy guard intent): embedder must NOT learn scroll + // position on a text-fragment-navigated page. + // ACTUAL: callback fires with 60% (the match position) -> LEAK. + verify(mEngagementSignalsCallback, never()) + .onGreatestScrollPercentageIncreased(anyInt(), any(Bundle.class)); + verify(mEngagementSignalsCallback, never()) + .onVerticalScrollEvent(anyBoolean(), any(Bundle.class)); + } + + @Test + public void poc_textFragmentGuardBypass_nonCommittingNavigation() { + // Vector [B]: after the text-fragment page commits (mSignalsPaused=true), + // the embedder reuses the same CCT session to navigate to a URL that + // returns HTTP 204 (or Content-Disposition:attachment). + // DidStartNavigation fires with a fragment-less URL -> guard cleared. + // The 204 navigation never commits, so navigationEntryCommitted() is + // not called: max-scroll is not reset and the victim text-fragment + // page is still on screen at the match offset. + initializeTabForTest(); + GestureStateListener listener = captureGestureStateListener(ON_SCROLL_END); + WebContentsObserver webContentsObserver = captureWebContentsObserver(); + + // 1) Direct navigation to victim#:~:text=selector. Guard correctly set. + var startHandle = + NavigationHandle.createForTesting( + JUnitTestGURLs.TEXT_FRAGMENT_URL, false, 0, false); + webContentsObserver.didStartNavigationInPrimaryMainFrame(startHandle); + LoadCommittedDetails commit = + new LoadCommittedDetails( + 0, + JUnitTestGURLs.TEXT_FRAGMENT_URL, + false, + /* isSameDocument= */ false, + /* isMainFrame= */ true, + 200); + webContentsObserver.navigationEntryCommitted(commit); + // Page auto-scrolled to match at 60%. + + // 2) Embedder fires a second same-session intent at an HTTP-204 URL. + // DidStartNavigation fires (web_contents_observer.h:308 — fires + // for navigations that never commit). URL has no fragment, so + // mSignalsPaused is unconditionally overwritten to false. + var nonCommitHandle = + NavigationHandle.createForTesting(JUnitTestGURLs.HTTP_URL, false, 0, false); + webContentsObserver.didStartNavigationInPrimaryMainFrame(nonCommitHandle); + // 204 -> navigationEntryCommitted() is *NOT* called. + // resetMaxScrollPercentage() is *NOT* called. Victim page still shown. + + // 3) User does one small down-scroll on the still-visible victim page. + listener.onScrollStarted(60, SCROLL_EXTENT, /* isDirectionUp= */ false); + when(mRenderCoordinatesImpl.getScrollYPixInt()).thenReturn(63); + listener.onScrollOffsetOrExtentChanged(63, SCROLL_EXTENT); + listener.onScrollEnded(63, SCROLL_EXTENT); + RobolectricUtil.runAllBackgroundAndUi(); + + // EXPECTED (privacy guard intent): embedder must NOT learn scroll + // position on the still-visible text-fragment page. + // ACTUAL: callback fires with 60% -> LEAK. + verify(mEngagementSignalsCallback, never()) + .onGreatestScrollPercentageIncreased(anyInt(), any(Bundle.class)); + verify(mEngagementSignalsCallback, never()) + .onVerticalScrollEvent(anyBoolean(), any(Bundle.class)); + } + + @Test + public void poc_textFragmentGuardBypass_lateCallbackRegistration() { + // Vector [C]: the embedder defers setEngagementSignalsCallback() until + // *after* the text-fragment navigation has already started/committed. + // The observer is constructed lazily with mSignalsPaused defaulting to + // false; neither onAttachedToInitialTab() nor + // maybeStartSendingRealTimeEngagementSignals() inspects the tab's + // current URL. initializeTabForTest() models exactly this: the + // observer attaches after the page is already at the match offset and + // never receives didStartNavigationInPrimaryMainFrame for it. + initializeTabForTest(); + GestureStateListener listener = captureGestureStateListener(ON_SCROLL_END); + + // Page is already auto-scrolled to the text-fragment match (60%). + // User does one small down-scroll. + listener.onScrollStarted(60, SCROLL_EXTENT, /* isDirectionUp= */ false); + when(mRenderCoordinatesImpl.getScrollYPixInt()).thenReturn(63); + listener.onScrollOffsetOrExtentChanged(63, SCROLL_EXTENT); + listener.onScrollEnded(63, SCROLL_EXTENT); + RobolectricUtil.runAllBackgroundAndUi(); + + // EXPECTED (privacy guard intent): no scroll-position leak. + // ACTUAL: callback fires with 60% because mSignalsPaused was never + // seeded from the committed URL. + verify(mEngagementSignalsCallback, never()) + .onGreatestScrollPercentageIncreased(anyInt(), any(Bundle.class)); + } + // ===== end POC ===== + @Test public void pauseAndUnpauseSignalsOnPageWithTextFragment() { initializeTabForTest();