CVE-2026-106216
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/android/javatests/src/org/chromium/chrome/browser/tab/TabContextMenuItemDelegateTest.java |
modified |
Files Changed
chrome/android/javatests/src/org/chromium/chrome/browser/read_later/ReadLaterContextMenuTest.javachrome/android/java/src/org/chromium/chrome/browser/tab/DEPSchrome/android/javatests/src/org/chromium/chrome/browser/tab/TabContextMenuItemDelegateTest.javachrome/android/java/src/org/chromium/chrome/browser/contextmenu/ChromeContextMenuPopulator.javachrome/android/java/src/org/chromium/chrome/browser/tab/TabContextMenuItemDelegate.java
Patch
From 0a03d6e653ec01ecdec3894cc6aa55e4bd077b15 Mon Sep 17 00:00:00 2001 From: Brandon Wylie <[email protected]> Date: Fri, 18 Sep 2026 13:31:46 -0700 Subject: [PATCH] Remove offline page request when adding to Reading List on Android When selecting "Add to Reading List" from the link context menu on Android, TabContextMenuItemDelegate previously requested an offline page save via RequestCoordinatorBridge in addition to adding the URL to the Reading List model. That background save fetched the target page with the regular profile's cookies and no initiator origin, so the network stack treated it as trusted browser UI and attached SameSite=Strict cookies to a request that originated from a link on a web page. It was also reachable from Incognito, where the fetch ran against the original profile. This behavior diverged from other Reading List entry points across Chromium platforms, which only update the metadata without triggering secondary background page fetches. Remove the offline page save request and the associated dependencies to standardize Reading List handling. Bug: 518076654 Change-Id: Icf8ba5c817d918a29ef493d229cb25fa8f078811 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8423234 Commit-Queue: Brandon Wylie <[email protected]> Reviewed-by: Sky Malice <[email protected]> Cr-Commit-Position: refs/heads/main@{#1701057} --- diff --git a/chrome/android/java/src/org/chromium/chrome/browser/contextmenu/ChromeContextMenuPopulator.java b/chrome/android/java/src/org/chromium/chrome/browser/contextmenu/ChromeContextMenuPopulator.java index 729b4e6..2a381e14 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/contextmenu/ChromeContextMenuPopulator.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/contextmenu/ChromeContextMenuPopulator.java @@ -1427,7 +1427,6 @@ mParams.getAdditionalNavigationParams()); } else if (itemId == R.id.contextmenu_read_later) { recordContextMenuSelection(ContextMenuUma.Action.READ_LATER); - // TODO(crbug.com/40156623): Download the page to offline page backend. String title = mParams.getTitleText(); if (TextUtils.isEmpty(title)) { title = mParams.getLinkText(); diff --git a/chrome/android/java/src/org/chromium/chrome/browser/tab/TabContextMenuItemDelegate.java b/chrome/android/java/src/org/chromium/chrome/browser/tab/TabContextMenuItemDelegate.java index 7a635b81..fe4763a3 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/tab/TabContextMenuItemDelegate.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/tab/TabContextMenuItemDelegate.java @@ -37,8 +37,6 @@ import org.chromium.chrome.browser.incognito.IncognitoUtils; import org.chromium.chrome.browser.multiwindow.MultiInstanceOrchestratorFactory; import org.chromium.chrome.browser.multiwindow.MultiWindowUtils; -import org.chromium.chrome.browser.offlinepages.OfflinePageBridge; -import org.chromium.chrome.browser.offlinepages.RequestCoordinatorBridge; import org.chromium.chrome.browser.preferences.Pref; import org.chromium.chrome.browser.price_tracking.PriceDropNotificationManagerFactory; import org.chromium.chrome.browser.printing.TabPrinter; @@ -525,13 +523,6 @@ mBottomSheetControllerSupplier.get(), new BookmarkManagerOpenerImpl(), PriceDropNotificationManagerFactory.create(mTab.getProfile())); - - // Add to offline pages. - assumeNonNull(RequestCoordinatorBridge.getForProfile(profile)) - .savePageLater( - url.getSpec(), - OfflinePageBridge.BOOKMARK_NAMESPACE, - /* userRequested= */ true); }); } diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/read_later/ReadLaterContextMenuTest.java b/chrome/android/javatests/src/org/chromium/chrome/browser/read_later/ReadLaterContextMenuTest.java index 2d15a27..0ff742d 100644 --- a/chrome/android/javatests/src/org/chromium/chrome/browser/read_later/ReadLaterContextMenuTest.java +++ b/chrome/android/javatests/src/org/chromium/chrome/browser/read_later/ReadLaterContextMenuTest.java @@ -14,9 +14,8 @@ import static org.hamcrest.Matchers.not; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -30,6 +29,7 @@ import androidx.test.platform.app.InstrumentationRegistry; import org.hamcrest.Matcher; +import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -39,6 +39,7 @@ import org.mockito.junit.MockitoRule; import org.chromium.base.Callback; +import org.chromium.base.ThreadUtils; import org.chromium.base.test.util.Batch; import org.chromium.base.test.util.CommandLineFlags; import org.chromium.base.test.util.CriteriaHelper; @@ -46,16 +47,20 @@ import org.chromium.base.test.util.Restriction; import org.chromium.chrome.R; import org.chromium.chrome.browser.app.ChromeActivity; +import org.chromium.chrome.browser.bookmarks.BookmarkModel; import org.chromium.chrome.browser.feature_engagement.TrackerFactory; import org.chromium.chrome.browser.flags.ChromeSwitches; import org.chromium.chrome.browser.offlinepages.RequestCoordinatorBridge; import org.chromium.chrome.browser.offlinepages.RequestCoordinatorBridgeJni; +import org.chromium.chrome.browser.profiles.ProfileManager; import org.chromium.chrome.browser.tab.Tab; import org.chromium.chrome.test.ChromeJUnit4ClassRunner; import org.chromium.chrome.test.transit.ChromeTransitTestRules; import org.chromium.chrome.test.transit.FreshCtaTransitTestRule; import org.chromium.chrome.test.transit.page.WebPageStation; import org.chromium.chrome.test.util.browser.contextmenu.ContextMenuUtils; +import org.chromium.components.bookmarks.BookmarkId; +import org.chromium.components.bookmarks.BookmarkItem; import org.chromium.components.feature_engagement.FeatureConstants; import org.chromium.components.feature_engagement.Tracker; import org.chromium.components.feature_engagement.TriggerDetails; @@ -105,6 +110,32 @@ RequestCoordinatorBridgeJni.setInstanceForTesting(mRequestCoordinatorBridgeJniMock); } + @After + public void tearDown() { + // This class is batched and only the Activity is recreated between tests, so the reading + // list would otherwise leak from one test into the next. That would let + // waitForReadingListEntry() succeed on a stale entry before the tap under test had done + // anything, silently disarming the assertions that follow it. + ThreadUtils.runOnUiThreadBlocking( + () -> { + BookmarkModel model = + BookmarkModel.getForProfile(ProfileManager.getLastUsedRegularProfile()); + if (model.isBookmarkModelLoaded()) { + model.removeAllUserBookmarks(); + } + }); + // Wait for the removal to land so the next test starts from a known-empty reading list. + CriteriaHelper.pollUiThread( + () -> { + BookmarkModel model = + BookmarkModel.getForProfile(ProfileManager.getLastUsedRegularProfile()); + if (!model.isBookmarkModelLoaded()) return true; + BookmarkId readingListFolder = model.getDefaultReadingListFolder(); + return readingListFolder == null + || model.getChildIds(readingListFolder).isEmpty(); + }); + } + @Test @MediumTest @Restriction({DeviceFormFactor.PHONE}) @@ -135,7 +166,7 @@ @Test @MediumTest @Restriction({DeviceFormFactor.PHONE}) - public void testContextMenuAddToOfflinePage() throws Throwable { + public void testContextMenuAddToReadingList() throws Throwable { String url = mTestServer.getURL(CONTEXT_MENU_TEST_URL); mActivityTestRule.loadUrlInNewTab(url); ChromeActivity activity = mActivityTestRule.getActivity(); @@ -147,8 +178,52 @@ CONTEXT_MENU_LINK_DOM_ID, R.id.contextmenu_read_later); String linkUrl = mTestServer.getURL(CONTEXT_MENU_LINK_URL); - verify(mRequestCoordinatorBridgeJniMock, timeout(CriteriaHelper.DEFAULT_MAX_TIME_TO_POLL)) - .savePageLater(any(), any(), eq(linkUrl), any(), any(), any(), anyBoolean()); + waitForReadingListEntry(tab, linkUrl); + verify(mRequestCoordinatorBridgeJniMock, never()) + .savePageLater(any(), any(), any(), any(), any(), any(), anyBoolean()); + } + + @Test + @MediumTest + @Restriction({DeviceFormFactor.PHONE}) + public void testContextMenuAddToReadingList_Incognito() throws Throwable { + String url = mTestServer.getURL(CONTEXT_MENU_TEST_URL); + mActivityTestRule.loadUrlInNewTab(url, /* incognito= */ true); + ChromeActivity activity = mActivityTestRule.getActivity(); + Tab tab = mActivityTestRule.getActivityTab(); + ContextMenuUtils.selectContextMenuItem( + InstrumentationRegistry.getInstrumentation(), + activity, + tab, + CONTEXT_MENU_LINK_DOM_ID, + R.id.contextmenu_read_later); + String linkUrl = mTestServer.getURL(CONTEXT_MENU_LINK_URL); + waitForReadingListEntry(tab, linkUrl); + verify(mRequestCoordinatorBridgeJniMock, never()) + .savePageLater(any(), any(), any(), any(), any(), any(), anyBoolean()); + } + + /** + * Waits until {@code url} shows up in the reading list. Waiting for this before asserting that + * no offline save happened is what keeps that assertion meaningful: it proves onReadLater + * actually ran, rather than the menu tap having silently done nothing. + */ + private void waitForReadingListEntry(Tab tab, String url) { + CriteriaHelper.pollUiThread( + () -> { + BookmarkModel model = + BookmarkModel.getForProfile(tab.getProfile().getOriginalProfile()); + if (!model.isBookmarkModelLoaded()) return false; + BookmarkId readingListFolder = model.getDefaultReadingListFolder(); + if (readingListFolder == null) return false; + for (BookmarkId childId : model.getChildIds(readingListFolder)) { + BookmarkItem item = model.getBookmarkById(childId); + if (item != null && item.getUrl().getSpec().equals(url)) { + return true; + } + } + return false; + }); } private ViewInteraction waitForHelpBubble(Matcher<View> matcher) { diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/tab/TabContextMenuItemDelegateTest.java b/chrome/android/javatests/src/org/chromium/chrome/browser/tab/TabContextMenuItemDelegateTest.java index 2acc029..1b4d8d15 100644 --- a/chrome/android/javatests/src/org/chromium/chrome/browser/tab/TabContextMenuItemDelegateTest.java +++ b/chrome/android/javatests/src/org/chromium/chrome/browser/tab/TabContextMenuItemDelegateTest.java @@ -44,6 +44,7 @@ import org.chromium.base.test.util.MinAndroidSdkLevel; import org.chromium.base.test.util.Restriction; import org.chromium.chrome.browser.ChromeTabbedActivity; +import org.chromium.chrome.browser.bookmarks.BookmarkModel; import org.chromium.chrome.browser.flags.ActivityType; import org.chromium.chrome.browser.flags.ChromeSwitches; import org.chromium.chrome.browser.multiwindow.MultiInstanceManager.CloseWindowAppSource; @@ -52,6 +53,8 @@ import org.chromium.chrome.test.ChromeJUnit4ClassRunner; import org.chromium.chrome.test.transit.ChromeTransitTestRules; import org.chromium.chrome.test.transit.FreshCtaTransitTestRule; +import org.chromium.components.bookmarks.BookmarkId; +import org.chromium.components.bookmarks.BookmarkItem; import org.chromium.components.browser_ui.bottomsheet.BottomSheetController; import org.chromium.content_public.common.Referrer; import org.chromium.ui.base.DeviceFormFactor; @@ -239,6 +242,41 @@ } } + @Test + @SmallTest + public void testOnReadLater_AddsToReadingList() { + createContextMenuForCurrentTab(); + GURL testUrl = new GURL("https://example.com/sample_article"); + String testTitle = "Sample Article"; + + ThreadUtils.runOnUiThreadBlocking( + () -> { + mContextMenuDelegate.onReadLater(testUrl, testTitle); + }); + + CriteriaHelper.pollUiThread( + () -> { + ChromeTabbedActivity cta = mActivityTestRule.getActivity(); + BookmarkModel bookmarkModel = + BookmarkModel.getForProfile( + cta.getActivityTab().getProfile().getOriginalProfile()); + if (!bookmarkModel.isBookmarkModelLoaded()) { + return false; + } + BookmarkId readingListFolder = bookmarkModel.getDefaultReadingListFolder(); + if (readingListFolder == null) { + return false; + } + for (BookmarkId id : bookmarkModel.getChildIds(readingListFolder)) { + BookmarkItem item = bookmarkModel.getBookmarkById(id); + if (item != null && item.getUrl().equals(testUrl)) { + return true; + } + } + return false; + }); + } + private void createContextMenuForCurrentTab() { ThreadUtils.runOnUiThreadBlocking( () -> { diff --git a/chrome/android/java/src/org/chromium/chrome/browser/tab/DEPS b/chrome/android/java/src/org/chromium/chrome/browser/tab/DEPS index 3bd69561..136b14a8 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/tab/DEPS +++ b/chrome/android/java/src/org/chromium/chrome/browser/tab/DEPS @@ -36,9 +36,6 @@ "+chrome/android/java/src/org/chromium/chrome/browser/metrics/UkmRecorder.java", "+chrome/android/java/src/org/chromium/chrome/browser/multiwindow/MultiWindowUtils.java", "+chrome/android/java/src/org/chromium/chrome/browser/native_page/NativePageFactory.java", - "+chrome/android/java/src/org/chromium/chrome/browser/offlinepages/OfflinePageBridge.java", - "+chrome/android/java/src/org/chromium/chrome/browser/offlinepages/OfflinePageOrigin.java", - "+chrome/android/java/src/org/chromium/chrome/browser/offlinepages/RequestCoordinatorBridge.java", "+chrome/android/java/src/org/chromium/chrome/browser/paint_preview/PaintPreviewTabHelper.java", "+chrome/android/java/src/org/chromium/chrome/browser/policy/PolicyAuditor.java", "+chrome/android/java/src/org/chromium/chrome/browser/printing/TabPrinter.java",
Original Bug Report
SameSite=Strict bypass and Incognito leak via 'Add to Reading List' on Android
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: Selecting ‘Add to Reading List’ from the link context menu in Chrome for Android triggers a background load in a hidden WebContents. Because this navigation is browser-initiated without an initiator origin, it potentials a bypass of SameSite=Strict cookies and spoofing of Fetch Metadata. Additionally, if triggered from an Incognito tab, it leaks the navigation and executes the credentialed load inside the user’s regular profile.
Affected files:
chrome/android/java/src/org/chromium/chrome/browser/tab/TabContextMenuItemDelegate.javacomponents/offline_pages/content/background_loader/background_loader_contents.ccchrome/browser/offline_pages/background_loader_offliner.ccchrome/android/java/src/org/chromium/chrome/browser/contextmenu/ChromeContextMenuPopulator.javacomponents/offline_pages/core/background/request_coordinator.cc
Estimated timestamp from git blame: 2021-04-28
Description
There is a potential design vulnerability in Chrome for Android where adding a link to the Reading List (‘Add to Reading List’ in the link context menu) triggers an immediate, invisible background navigation in a hidden, non-composited WebContents bound to the user’s regular profile, without preserving the initiator origin or the Incognito status of the source tab.
Because the background loader performs the navigation without an initiator origin, the navigation is treated as a trusted, browser-initiated top-level navigation. Consequently, the browser attaches SameSite=Strict cookies and sets Sec-Fetch-Site: none, bypasses standard CSRF protections (which rely on SameSite cookies or Fetch Metadata), and executes state-changing GET requests entirely invisibly to the user.
Code Flow and Mechanism
-
Context Menu Event Trigger: When a user long-presses an
<a>element and selects “Add to Reading List”,ChromeContextMenuPopulator.javaextracts the page-supplied link URL and triggers:tabItemDelegate.onReadLater(mParams.getUrl(), title); -
Incognito-to-Regular Profile Crossing: In
TabContextMenuItemDelegate.java, the delegate unconditionally retrieves the original profile:Profile profile = mTab.getProfile().getOriginalProfile();This causes any navigation initiated from an Incognito tab to cross the boundary and execute inside the user’s regular profile.
-
Queueing to Background Offliner: The delegate schedules a background save via:
RequestCoordinatorBridge.getForProfile(profile) .savePageLater(url.getSpec(), OfflinePageBridge.BOOKMARK_NAMESPACE, /* userRequested= */ true); -
Immediate Loading in Hidden WebContents: Because
userRequested = true,RequestCoordinatorinitiates immediate processing. It invokesBackgroundLoaderOffliner::LoadAndSave(), which resets and configures aBackgroundLoaderContentsobject (representing a hidden, non-composited backgroundWebContentsunder the regularBrowserContext). -
Browser-Initiated Navigation:
BackgroundLoaderContents::LoadPagetriggers the load using:web_contents_->GetController().LoadURL( url, content::Referrer(), ui::PAGE_TRANSITION_LINK, std::string());This call is translated into a default-initialized
LoadURLParamswhereinitiator_originisstd::nullopt(indicating browser-initiated trusted UI, such as a bookmark or typed URL). -
SameSite=Strict and Fetch Metadata Bypass: Because the navigation is marked as top-level and browser-initiated, standard SameSite checks are bypassed. The user’s
SameSite=Strictsession cookies are attached, and theSec-Fetch-Siteheader is set tonone. This allows the background request to spoof a user-initiated bookmark/omnibox navigation, bypassing server-side security checks.
Potential Trigger Steps
Note: These are potential steps based on source code analysis; our tooling does not currently have the capability to run code to verify this.
- A victim logs into a target site (e.g.,
https://bank.example) where aSameSite=Strictsession cookie is active. - The victim visits a potentially malicious or compromised site
https://attacker.comcontaining a link pointing to a state-changing GET endpoint:<a href="https://bank.example/action?do=something_harmful">Read this important update later</a> - The victim is enticed to long-press the link and select Add to Reading List from the context menu.
- Within seconds, Chrome for Android schedules and executes a background GET request to the link target under the user’s regular profile.
- The request is sent with the victim’s session cookies and
Sec-Fetch-Site: noneheaders, executing the target action silently in the background.
Suggested Fix
- Isolate OTR Sessions: Do not use
mTab.getProfile().getOriginalProfile()unconditionally. IfmTab.getProfile().isOffTheRecord()is true, background offlining/saving should either be disabled, or restricted to an OTR background loader context that does not access the regular profile’s cookie jar. - Preserve Initiator Origin: Capture the initiator origin of the link (e.g.,
mTab.getWebContents().getMainFrame().getLastCommittedOrigin()) and pass it down toSavePageLaterParamsandLoadURLParams. If the navigation is triggered from a cross-site initiator, ensure thatinitiator_originis populated so that SameSite restrictions are enforced and Fetch Metadata is correctly populated (e.g.,Sec-Fetch-Site: cross-site). - De-privilege Background Loads: Restrict the attachment of credentials/cookies for background page-distillation and offliner loads unless the page load is explicitly verified as a same-origin request or is executed with restricted credentials.
Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040
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.