CVE-2026-3932
Overview
Background
- PDF native page (Android)
- Chrome on Android can show PDFs inline as a native page addressed by an internal URL such as
chrome-native://pdf/link?url=<encoded original URL>;PdfUtils.decodePdfPageUrlrecovers the original URL from it. - PDF re-download
- when a finished navigation lands on such a PDF page URL that
maybeShowNativePagecannot display (e.g. going back to it from a non-PDF page),TabImpl.handleDidFinishNavigationloads the decoded URL again to fetch the PDF. - Browser- vs. renderer-initiated navigation
LoadUrlParamsbuilt with only a URL is treated as browser-initiated with no initiator origin, which carries more trust (for example inSameSite=Strictcookie decisions and scheme restrictions) than a renderer-initiated navigation from a known origin.
Root Cause Analysis
In TabImpl.handleDidFinishNavigation, when maybeShowNativePage returned false, the code called PdfUtils.decodePdfPageUrl(url.getSpec()). Any non-null result was passed to loadUrl(new LoadUrlParams(downloadUrl)). That step had two flaws. First, the decoded URL was not restricted by scheme, so a PDF page URL whose embedded url parameter was something like chrome://version (the new PDF_LINK_ENCODED_INVALID test case) would be loaded. Second, the new load dropped the original navigation’s isRendererInitiated and initiator origin, so it ran as a fresh browser-initiated navigation. Per the commit and the new comment, that could escalate privileges and bypass SameSite=Strict cookie protection. The commit says the re-download can be triggered when the current page is not a PDF, so a page that is not a PDF can drive this path.
The patch makes three changes. It adds PdfUtils.getPdfReDownloadUrl, which returns the decoded URL only if it starts with http:// or https:// and otherwise returns null (so showRenderedPage() runs instead). It adds isRendererInitiated and initiatorOrigin parameters to handleDidFinishNavigation. It copies them onto the re-download LoadUrlParams with setIsRendererInitiated and setInitiatorOrigin. TabWebContentsObserver now passes navigation.isRendererInitiated() and navigation.getInitiatorOrigin().
Attack Path
- Reach a PDF page URL
a navigation in the tab finishes on a
chrome-native://pdf/link?url=...style URL whose embedded target the attacker chose, for example via going back to a PDF URL from a non-PDF page as the commit describes. Exactly how the attacker gets this URL into the navigation is not shown in the diff (inferred). - Native page not shown
maybeShowNativePagereturnsfalse, sohandleDidFinishNavigationdecodes the embedded URL for a re-download. - Laundered navigation
before the patch, the decoded URL (of any scheme, e.g.
chrome://version) was loaded with a bareLoadUrlParams, so it ran as a browser-initiated navigation with no initiator origin. - Policy bypass
this lets web content reach URLs or schemes it could not normally navigate to, and makes a cross-site request look browser-initiated so that
SameSite=Strictcookies are sent. The commit names the cookie bypass; any further consequence on the reached page is (inferred).
Impact Assessment
chrome://, and as a browser-initiated navigation that gets around SameSite=Strict cookie protection. Preconditions: Android only (TabImpl, PdfUtils); the inline PDF path must be active (PdfUtils.shouldOpenPdfInline); and the tab must commit a PDF page URL that maybeShowNativePage won’t show, as with the back-navigation-to-PDF flow the commit describes. Whether this needs a user gesture is not shown. The vendor rates it medium severity (“Insufficient policy enforcement in PDF”).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/android/java/src/org/chromium/chrome/browser/tab/TabImpl.java |
modified | |
ifchrome/browser/ui/android/pdf/java/src/org/chromium/chrome/browser/pdf/PdfUtils.java |
modified |
Files Changed
chrome/android/java/src/org/chromium/chrome/browser/tab/TabImpl.javachrome/android/java/src/org/chromium/chrome/browser/tab/TabWebContentsObserver.javachrome/browser/ui/android/pdf/java/src/org/chromium/chrome/browser/pdf/PdfUtils.javachrome/browser/ui/android/pdf/java/src/org/chromium/chrome/browser/pdf/PdfUtilsUnitTest.java
Audit Directions
- Browser-side follow-up navigationslook for other Android/Java code that reacts to a finished or failed navigation by calling
loadUrl(new LoadUrlParams(url)), such as native-page fallbacks, offline pages, download retries or interstitial “try again” actions. Check that those loads keepisRendererInitiated,initiatorOriginand referrer. - Encoded-URL wrappersaudit internal wrapper URLs that embed a target URL in a query parameter (
chrome-native://,chrome://handlers,PdfUtils.encodePdfPageUrlcounterparts). Check that the decoded target is limited by scheme before it is navigated to, opened or downloaded. - History-driven re-executionreview session-history and back/forward restoration paths where a stored entry makes the browser start a new load. Check whether the stored URL or its initiator can be influenced by a page that is not the original initiator.
Patch
From 2265687230f6a3642e766a68d7c3b201ced0bbf6 Mon Sep 17 00:00:00 2001 From: Shu Yang <[email protected]> Date: Fri, 06 Feb 2026 13:32:39 -0800 Subject: [PATCH] Restrict PDF re-downloads to safe schemes and preserve initiator Going back to a PDF URL may require a re-download, and this can be triggered when the current page is not a PDF. Ensure that these navigations can't elevate their privileges by limiting to HTTP(S) and preserving information about the initiator. Bug: 478296121 Change-Id: Ic08a4aa797b0ba11f7010714570e4e9d3be31a48 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7520272 Reviewed-by: Charlie Reis <[email protected]> Reviewed-by: Sirisha Kavuluru <[email protected]> Commit-Queue: Shu Yang <[email protected]> Cr-Commit-Position: refs/heads/main@{#1581094} --- diff --git a/chrome/android/java/src/org/chromium/chrome/browser/tab/TabImpl.java b/chrome/android/java/src/org/chromium/chrome/browser/tab/TabImpl.java index ae0b4dee..95f7a504 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/tab/TabImpl.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/tab/TabImpl.java @@ -112,6 +112,7 @@ import org.chromium.ui.base.ViewAndroidDelegate; import org.chromium.ui.base.WindowAndroid; import org.chromium.url.GURL; +import org.chromium.url.Origin; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -1854,8 +1855,15 @@ * @param url The URL that was loaded. * @param transitionType The transition type to the current URL. * @param isPdf Whether the navigation is for PDF content. + * @param isRendererInitiated Whether the navigation is initiated by renderer. + * @param initiatorOrigin The Origin that initiated this navigation. */ - void handleDidFinishNavigation(GURL url, int transitionType, boolean isPdf) { + void handleDidFinishNavigation( + GURL url, + int transitionType, + boolean isPdf, + boolean isRendererInitiated, + @Nullable Origin initiatorOrigin) { mIsNativePageCommitPending = false; boolean isReload = (transitionType & PageTransition.CORE_MASK) == PageTransition.RELOAD; // Set isPdf param based on the url. This is because the isPdf param in NavigationHandle is @@ -1866,11 +1874,20 @@ PdfUtils.shouldOpenPdfInline(isIncognito()) && PdfUtils.isDownloadedPdf(url.getSpec()); if (!maybeShowNativePage(url.getSpec(), isReload, isPdf ? new PdfInfo() : null)) { - String downloadUrl = PdfUtils.decodePdfPageUrl(url.getSpec()); + // This is restricted to HTTP(S) URLs specifically, as these are the only schemes that + // necessitate a PDF re-download. + String downloadUrl = PdfUtils.getPdfReDownloadUrl(url.getSpec()); if (downloadUrl != null) { - // When the download url is not null, we are on a pdf native page which requires - // re-download. Load the download url to trigger the re-download. - loadUrl(new LoadUrlParams(downloadUrl)); + // When the download url is not null, we are navigating to a pdf native page which + // requires re-download. Load the download url to trigger the re-download. + var param = new LoadUrlParams(downloadUrl); + // To avoid a SameSite=strict cookie bypass, it is important to preserve whether the + // navigation was renderer initiated and what its initiator origin was. + param.setIsRendererInitiated(isRendererInitiated); + if (initiatorOrigin != null) { + param.setInitiatorOrigin(initiatorOrigin); + } + loadUrl(param); } else { showRenderedPage(); } diff --git a/chrome/android/java/src/org/chromium/chrome/browser/tab/TabWebContentsObserver.java b/chrome/android/java/src/org/chromium/chrome/browser/tab/TabWebContentsObserver.java index eec3439..18b339eb 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/tab/TabWebContentsObserver.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/tab/TabWebContentsObserver.java @@ -318,7 +318,11 @@ mTab.updateTitle(); mTab.handleDidFinishNavigation( - navigation.getUrl(), navigation.pageTransition(), navigation.isPdf()); + navigation.getUrl(), + navigation.pageTransition(), + navigation.isPdf(), + navigation.isRendererInitiated(), + navigation.getInitiatorOrigin()); mTab.setIsShowingErrorPage(navigation.isErrorPage()); // TODO(crbug.com/40264745) remove this call. onUrlUpdated should have been called diff --git a/chrome/browser/ui/android/pdf/java/src/org/chromium/chrome/browser/pdf/PdfUtils.java b/chrome/browser/ui/android/pdf/java/src/org/chromium/chrome/browser/pdf/PdfUtils.java index 61383fa..222a9473 100644 --- a/chrome/browser/ui/android/pdf/java/src/org/chromium/chrome/browser/pdf/PdfUtils.java +++ b/chrome/browser/ui/android/pdf/java/src/org/chromium/chrome/browser/pdf/PdfUtils.java @@ -316,6 +316,30 @@ } /** + * Extracts a valid HTTP(S) URL from a PDF page URL for re-downloading. + * + * <p>This method decodes the provided {@code originalUrl} and verifies that the result uses a + * supported scheme (HTTP or HTTPS). + * + * @param originalUrl The original, potentially encoded, URL string to process. + * @return The decoded URL string if it is a valid HTTP(S) URL; {@code null} otherwise. + */ + public static @Nullable String getPdfReDownloadUrl(String originalUrl) { + String decodedUrl = decodePdfPageUrl(originalUrl); + + if (decodedUrl == null) { + return null; + } + + if (decodedUrl.startsWith(UrlConstants.HTTP_URL_PREFIX) + || decodedUrl.startsWith(UrlConstants.HTTPS_URL_PREFIX)) { + return decodedUrl; + } + + return null; + } + + /** * Encode content uri if it is PDF MIME type. * * @param uri The uri to be encoded. diff --git a/chrome/browser/ui/android/pdf/java/src/org/chromium/chrome/browser/pdf/PdfUtilsUnitTest.java b/chrome/browser/ui/android/pdf/java/src/org/chromium/chrome/browser/pdf/PdfUtilsUnitTest.java index 91d1c9c..86c1629 100644 --- a/chrome/browser/ui/android/pdf/java/src/org/chromium/chrome/browser/pdf/PdfUtilsUnitTest.java +++ b/chrome/browser/ui/android/pdf/java/src/org/chromium/chrome/browser/pdf/PdfUtilsUnitTest.java @@ -49,6 +49,8 @@ private static final String PDF_BLOB_URL = "blob:https://www.foo.com/abc"; private static final String PDF_LINK_ENCODED = "chrome-native://pdf/link?url=https%3A%2F%2Fwww.foo.com%2Ftestfiles%2Fpdf%2Fsample.pdf"; + private static final String PDF_LINK_ENCODED_INVALID = + "chrome-native://pdf/link?url=chrome%3A%2F%2Fversion"; private static final String FILE_PATH = "/media/external/downloads/sample.pdf"; private static final String FILE_NAME = "sample.pdf"; private static final String IMAGE_FILE_URL = "file:///media/external/downloads/sample.jpg"; @@ -238,6 +240,18 @@ } @Test + public void testGetPdfReDownloadUrl_Https() { + String downloadUrl = PdfUtils.getPdfReDownloadUrl(PDF_LINK_ENCODED); + Assert.assertEquals("The re-download url should match", PDF_LINK, downloadUrl); + } + + @Test + public void testGetPdfReDownloadUrl_Invalid() { + String downloadUrl = PdfUtils.getPdfReDownloadUrl(PDF_LINK_ENCODED_INVALID); + Assert.assertNull("The re-download url should be null", downloadUrl); + } + + @Test public void testEncodeDecodeUrlWithSpecialCharacter() { String encodedUrl = PdfUtils.encodePdfPageUrl(CONTENT_URL_SPECIAL_CHARACTER); String decodedUrl = PdfUtils.decodePdfPageUrl(encodedUrl);