Chrome · PDF › Android
CVE-2026-113026
Memory Corruption in PDF (Android)
Overview
Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/renderer_host/navigation_controller_android.cc |
modified |
Files Changed
chrome/android/junit/src/org/chromium/chrome/browser/tab/TabUnitTest.javachrome/android/java/src/org/chromium/chrome/browser/download/DownloadController.javaurl/BUILD.gncontent/browser/renderer_host/navigation_controller_android.ccchrome/android/junit/BUILD.gnurl/android/test/java/src/org/chromium/url/JUnitTestOrigins.javachrome/android/junit/src/org/chromium/chrome/browser/download/DownloadControllerUnitTest.javachrome/browser/download/android/download_controller.cc
Patch
From f38fc7b20380f126bd293b2584f895b54d3773b3 Mon Sep 17 00:00:00 2001
From: Liam Brady <lbrady@google.com>
Date: Wed, 23 Sep 2026 12:36:28 -0700
Subject: [PATCH] [Android] Record the initiator origin for inline PDF downloads
When a PDF navigation is intercepted for inline viewing on Android,
DownloadController.onPdfDownloadStarted() currently synthesizes a
chrome-native://pdf/link?url=<target> entry, but drops the initiator
origin of the originating navigation.
That entry is replayed on every history traversal and tab restore, where
TabImpl.handleDidFinishNavigation() re-downloads the underlying URL
using the stored initiator. With none recorded, the request reaches the
network stack with request_initiator == nullopt, which
net::cookie_util::ComputeSameSiteContext() treats as same-site. This
results in the re-download getting SAME_SITE_STRICT and the target
site's SameSite=Strict cookies, despite being caused by a cross-site
document.
This CL plumbs DownloadItem::GetRequestInitiator() through JNI to
onPdfDownloadStarted() and sets it on the LoadUrlParams for the
chrome-native:// page, so the stored entry (and every later re-download)
carries the original initiator. Because PageState serialization already
stores the entry's initiator origin, populating it is sufficient to
survive freeze and restore without modifying PageState serialization.
This CL also modifies NavigationControllerAndroid::LoadUrl() to ensure
that some initiator origin exists for all renderer-initiated
navigations. A renderer-initiated navigation with no initiator origin
violates the DCHECK asserted in
NavigationControllerImpl::CreateNavigationRequestFromLoadParams() /
LoadURLWithParams() and hits the same cookie logic, so this falls back
to an opaque origin and fails closed. Browser-initiated navigations with
a null initiator are left alone.
Bug: 500173014
Change-Id: I49d5b19581bb54b8977263bb767a1107930986d1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8421321
Commit-Queue: Liam Brady <lbrady@google.com>
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Reviewed-by: Yaron Friedman <yfriedman@chromium.org>
Reviewed-by: Daniel Cheng <dcheng@chromium.org>
Reviewed-by: Min Qin <qinmin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1703668}
---
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/download/DownloadController.java b/chrome/android/java/src/org/chromium/chrome/browser/download/DownloadController.java
index 6700033..75a5626 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/download/DownloadController.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/download/DownloadController.java
@@ -4,6 +4,8 @@
package org.chromium.chrome.browser.download;
+import androidx.annotation.VisibleForTesting;
+
import org.jni_zero.CalledByNative;
import org.jni_zero.JniType;
import org.jni_zero.NativeMethods;
@@ -21,6 +23,7 @@
import org.chromium.content_public.browser.navigation_controller.LoadURLType;
import org.chromium.ui.base.MimeTypeUtils;
import org.chromium.url.GURL;
+import org.chromium.url.Origin;
/** Java counterpart of android DownloadController. Owned by native. */
@NullMarked
@@ -105,8 +108,20 @@
.enqueueNewDownload(new DownloadItem(true, info), true);
}
+ /**
+ * Called when an inline PDF download starts, to show the pdf native page for it.
+ *
+ * @param tab Tab the download was triggered from.
+ * @param downloadInfo Information about the download.
+ * @param initiatorOrigin Origin that initiated the download, or null if the download was
+ * browser-initiated (e.g. from the omnibox, a bookmark or an external intent).
+ */
@CalledByNative
- private static void onPdfDownloadStarted(Tab tab, DownloadInfo downloadInfo) {
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ static void onPdfDownloadStarted(
+ Tab tab,
+ DownloadInfo downloadInfo,
+ @JniType("std::optional<url::Origin>") @Nullable Origin initiatorOrigin) {
if (!PdfUtils.shouldOpenPdfInline(tab.isIncognito())) {
return;
}
@@ -119,6 +134,9 @@
param.setIsPdf(true);
param.setLoadType(LoadURLType.PDF_ANDROID);
param.setVirtualUrlForSpecialCases(downloadUrl);
+ // Record the initiator so that the re-download triggered by a later history traversal or
+ // tab restore keeps the SameSite context of the navigation that caused this download.
+ param.setInitiatorOrigin(initiatorOrigin);
// If the download url matches the tab’s url, avoid duplicate navigation entries by
// replacing the current entry.
param.setShouldReplaceCurrentEntry(downloadUrl.equals(tab.getUrl().getSpec()));
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/download/DownloadControllerUnitTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/download/DownloadControllerUnitTest.java
new file mode 100644
index 0000000..3e8869d
--- /dev/null
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/download/DownloadControllerUnitTest.java
@@ -0,0 +1,103 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package org.chromium.chrome.browser.download;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.os.Build;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.annotation.Config;
+
+import org.chromium.base.test.BaseRobolectricTestRunner;
+import org.chromium.base.test.util.Feature;
+import org.chromium.build.annotations.Nullable;
+import org.chromium.chrome.browser.tab.Tab;
+import org.chromium.content_public.browser.LoadUrlParams;
+import org.chromium.url.GURL;
+import org.chromium.url.JUnitTestOrigins;
+import org.chromium.url.Origin;
+
+/**
+ * Unit tests for {@link DownloadController}'s inline PDF handling.
+ *
+ * <p>These cover the initiator origin recorded on the synthesized {@code chrome-native://pdf}
+ * entry. That entry is replayed on every later history traversal or tab restore, so whatever is
+ * stored here determines the SameSite context of the eventual re-download. See crbug.com/500173014.
+ *
+ * <p>VANILLA_ICE_CREAM satisfies {@code PdfUtils.isPlatformSupported()}, which gates inline PDF
+ * viewing.
+ */
+@RunWith(BaseRobolectricTestRunner.class)
+@Config(sdk = Build.VERSION_CODES.VANILLA_ICE_CREAM)
+public class DownloadControllerUnitTest {
+ private static final String TAB_URL = "https://initiator.example/landing";
+ private static final String PDF_URL = "https://victim.example/document.pdf";
+
+ @Rule public MockitoRule mMockitoRule = MockitoJUnit.rule();
+
+ @Mock private Tab mTab;
+
+ private LoadUrlParams captureLoadUrlParams(@Nullable Origin initiatorOrigin) {
+ when(mTab.getUrl()).thenReturn(new GURL(TAB_URL));
+ when(mTab.isIncognito()).thenReturn(false);
+
+ DownloadInfo info = new DownloadInfo.Builder().setUrl(new GURL(PDF_URL)).build();
+ DownloadController.onPdfDownloadStarted(mTab, info, initiatorOrigin);
+
+ ArgumentCaptor<LoadUrlParams> captor = ArgumentCaptor.forClass(LoadUrlParams.class);
+ verify(mTab).loadUrl(captor.capture());
+ return captor.getValue();
+ }
+
+ /**
+ * A download initiated by a page must record its initiator on the synthesized entry. Without
+ * this, a later traversal re-downloads with no initiator, which
+ * net::cookie_util::ComputeSameSiteContext reads as browser-initiated and grants a
+ * SAME_SITE_STRICT context - the cross-site cookie bypass in crbug.com/500173014.
+ */
+ @Test
+ @Feature({"Download"})
+ public void testOnPdfDownloadStarted_PropagatesInitiatorOrigin() {
+ Origin initiatorOrigin = JUnitTestOrigins.createTuple("https", "initiator.example", 443);
+
+ LoadUrlParams param = captureLoadUrlParams(initiatorOrigin);
+
+ assertEquals(initiatorOrigin, param.getInitiatorOrigin());
+ assertTrue(param.getIsPdf());
+ assertEquals(PDF_URL, param.getVirtualUrlForSpecialCases());
+ // The initiator origin is what fixes the cookie bug; the navigation itself stays
+ // browser-initiated, since nothing here knows whether the triggering navigation was
+ // renderer-initiated.
+ assertFalse(param.getIsRendererInitiated());
+ }
+
+ /**
+ * A download with no initiator is genuinely browser-initiated, e.g. a PDF opened from the
+ * omnibox, a bookmark or an external intent. It must stay that way: fabricating an initiator
+ * here would strip SameSite=Strict cookies from the later re-download and break authenticated
+ * PDFs.
+ */
+ @Test
+ @Feature({"Download"})
+ public void testOnPdfDownloadStarted_NullInitiatorOriginStaysNull() {
+ LoadUrlParams param = captureLoadUrlParams(/* initiatorOrigin= */ null);
+
+ assertNull(param.getInitiatorOrigin());
+ assertFalse(param.getIsRendererInitiated());
+ assertTrue(param.getIsPdf());
+ }
+}
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/tab/TabUnitTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/tab/TabUnitTest.java
index 9648ecf..ad6372b 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/tab/TabUnitTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/tab/TabUnitTest.java
@@ -85,6 +85,8 @@
import org.chromium.content_public.browser.WebContentsObserver;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.url.GURL;
+import org.chromium.url.JUnitTestOrigins;
+import org.chromium.url.Origin;
import java.lang.ref.WeakReference;
@@ -1157,12 +1159,18 @@
}
private void handleDidFinishNavigation(TabImpl tab, GURL url) {
+ handleDidFinishNavigation(
+ tab, url, /* isRendererInitiated= */ false, /* initiatorOrigin= */ null);
+ }
+
+ private void handleDidFinishNavigation(
+ TabImpl tab, GURL url, boolean isRendererInitiated, @Nullable Origin initiatorOrigin) {
tab.handleDidFinishNavigation(
url,
/* transitionType= */ 0,
/* isPdf= */ false,
- /* isRendererInitiated= */ false,
- /* initiatorOrigin= */ null);
+ isRendererInitiated,
+ initiatorOrigin);
}
@Test
@@ -1350,4 +1358,67 @@
verify(mNavigationController, never()).loadUrl(any());
}
+
+ private void setUpTabWithWebContents() {
+ mTab.setNativePtrForTesting(1);
+ when(mWebContents.getNavigationController()).thenReturn(mNavigationController);
+ when(mWebContents.getTopLevelNativeWindow()).thenReturn(mWindowAndroid);
+ mTab.setWebContentsForTesting(mWebContents);
+ mTab.updateAttachment(mWindowAndroid, mDelegateFactory);
+ }
+
+ /**
+ * The re-download must reuse the initiator recorded on the pdf entry. Dropping it would send
+ * the request with no initiator, which net::cookie_util::ComputeSameSiteContext() treats as
+ * same-site, handing the target site's SameSite=Strict cookies to a cross-site initiator. See
+ * crbug.com/500173014.
+ */
+ @Test
+ public void testHandleDidFinishNavigation_PdfRedownload_PreservesInitiatorOrigin() {
+ setUpTabWithWebContents();
+
+ String httpUrl = "https://example.com/test.pdf";
+ String encodedUrl = PdfUtils.encodePdfPageUrl(httpUrl);
+ Origin initiatorOrigin = JUnitTestOrigins.createTuple("https", "initiator.example", 443);
+
+ handleDidFinishNavigation(
+ mTab, new GURL(encodedUrl), /* isRendererInitiated= */ true, initiatorOrigin);
+
+ verify(mNavigationController)
+ .loadUrl(
+ argThat(
+ params ->
+ httpUrl.equals(params.getUrl())
+ && params.getIsRendererInitiated()
+ && initiatorOrigin.equals(
+ params.getInitiatorOrigin())));
+ }
+
+ /**
+ * A browser-initiated traversal legitimately has no initiator, e.g. a PDF opened from the
+ * omnibox, a bookmark or an external intent. The re-download must stay browser-initiated with a
+ * null initiator so that the site's cookies are still sent; substituting an opaque origin here
+ * would strip SameSite=Strict cookies and break authenticated PDFs.
+ */
+ @Test
+ public void testHandleDidFinishNavigation_PdfRedownload_BrowserInitiatedKeepsNullInitiator() {
+ setUpTabWithWebContents();
+
+ String httpUrl = "https://example.com/test.pdf";
+ String encodedUrl = PdfUtils.encodePdfPageUrl(httpUrl);
+
+ handleDidFinishNavigation(
+ mTab,
+ new GURL(encodedUrl),
+ /* isRendererInitiated= */ false,
+ /* initiatorOrigin= */ null);
+
+ verify(mNavigationController)
+ .loadUrl(
+ argThat(
+ params ->
+ httpUrl.equals(params.getUrl())
+ && !params.getIsRendererInitiated()
+ && params.getInitiatorOrigin() == null));
+ }
}
diff --git a/chrome/browser/download/android/download_controller.cc b/chrome/browser/download/android/download_controller.cc
index 57f426f2..cea6ca0a 100644
--- a/chrome/browser/download/android/download_controller.cc
+++ b/chrome/browser/download/android/download_controller.cc
@@ -75,6 +75,7 @@
#include "ui/base/device_form_factor.h"
#include "ui/base/page_transition_types.h"
#include "url/android/gurl_android.h"
+#include "url/origin.h"
#if BUILDFLAG(SAFE_BROWSING_AVAILABLE)
#include "components/safe_browsing/content/common/file_type_policies.h"
@@ -374,8 +375,9 @@
JNIEnv* env = base::android::AttachCurrentThread();
ScopedJavaLocalRef<jobject> j_item =
DownloadManagerService::CreateJavaDownloadInfo(env, download_item);
- Java_DownloadController_onPdfDownloadStarted(env, tab->GetJavaObject(),
- j_item);
+ Java_DownloadController_onPdfDownloadStarted(
+ env, tab->GetJavaObject(), j_item,
+ download_item->GetRequestInitiator());
should_cancel_download = false;
}
}
diff --git a/content/browser/renderer_host/navigation_controller_android.cc b/content/browser/renderer_host/navigation_controller_android.cc
index aba04f78..34ff792 100644
--- a/content/browser/renderer_host/navigation_controller_android.cc
+++ b/content/browser/renderer_host/navigation_controller_android.cc
@@ -316,6 +316,15 @@
if (j_initiator_origin) {
params.initiator_origin =
url::Origin::FromJavaObject(env, j_initiator_origin);
+ } else if (params.is_renderer_initiated) {
+ // All renderer-initiated navigations must have an initiator origin (see the
+ // DCHECK in
+ // NavigationControllerImpl::CreateNavigationRequestFromLoadParams() /
+ // LoadURLWithParams()). Every Java caller is expected to supply an
+ // initiator, so report the bug but fail closed with an opaque origin rather
+ // than letting the navigation through with none.
+ base::debug::DumpWithoutCrashing();
+ params.initiator_origin = url::Origin();
}
if (input_start != 0)
diff --git a/url/android/test/java/src/org/chromium/url/JUnitTestOrigins.java b/url/android/test/java/src/org/chromium/url/JUnitTestOrigins.java
new file mode 100644
index 0000000..e3b6f17f
--- /dev/null
+++ b/url/android/test/java/src/org/chromium/url/JUnitTestOrigins.java
@@ -0,0 +1,28 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package org.chromium.url;
+
+/**
+ * Factories for {@link Origin}s usable from Robolectric tests.
+ *
+ * <p>{@link Origin#create(GURL)} and {@link Origin#createOpaqueOrigin()} both require native, so
+ * unit tests have to build origins from the Mojo representation instead. That representation is not
+ * meant to be used directly (see crbug.com/1156866), so it is wrapped here rather than duplicated
+ * in every test.
+ */
+public class JUnitTestOrigins {
+ private JUnitTestOrigins() {}
+
+ /** Returns the tuple origin (scheme, host, port), e.g. ("https", "example.com", 443). */
+ public static Origin createTuple(String scheme, String host, int port) {
+ org.chromium.url.internal.mojom.Origin mojoOrigin =
+ new org.chromium.url.internal.mojom.Origin();
+ mojoOrigin.scheme = scheme;
+ mojoOrigin.host = host;
+ mojoOrigin.port = (short) port;
+ // nonceIfOpaque is left at its default of null, i.e. a tuple (non-opaque) origin.
+ return new Origin(mojoOrigin);
+ }
+}
diff --git a/chrome/android/junit/BUILD.gn b/chrome/android/junit/BUILD.gn
index 833eb72..55ca2ffc 100644
--- a/chrome/android/junit/BUILD.gn
+++ b/chrome/android/junit/BUILD.gn
@@ -826,6 +826,7 @@
robolectric_library(
"chrome_junit_tests_org.chromium.chrome.browser.download") {
sources = [
+ "src/org/chromium/chrome/browser/download/DownloadControllerUnitTest.java",
"src/org/chromium/chrome/browser/download/DownloadOpenRequestTest.java",
"src/org/chromium/chrome/browser/download/DownloadSharedPreferenceEntryTest.java",
"src/org/chromium/chrome/browser/download/DownloadUtilsUnitTest.java",
diff --git a/url/BUILD.gn b/url/BUILD.gn
... (file diff truncated)
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/url/android/test/java/src/org/chromium/url/JUnitTestOrigins.java b/url/android/test/java/src/org/chromium/url/JUnitTestOrigins.java
new file mode 100644
index 0000000..e3b6f17f
--- /dev/null
+++ b/url/android/test/java/src/org/chromium/url/JUnitTestOrigins.java
@@ -0,0 +1,28 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package org.chromium.url;
+
+/**
+ * Factories for {@link Origin}s usable from Robolectric tests.
+ *
+ * <p>{@link Origin#create(GURL)} and {@link Origin#createOpaqueOrigin()} both require native, so
+ * unit tests have to build origins from the Mojo representation instead. That representation is not
+ * meant to be used directly (see crbug.com/1156866), so it is wrapped here rather than duplicated
+ * in every test.
+ */
+public class JUnitTestOrigins {
+ private JUnitTestOrigins() {}
+
+ /** Returns the tuple origin (scheme, host, port), e.g. ("https", "example.com", 443). */
+ public static Origin createTuple(String scheme, String host, int port) {
+ org.chromium.url.internal.mojom.Origin mojoOrigin =
+ new org.chromium.url.internal.mojom.Origin();
+ mojoOrigin.scheme = scheme;
+ mojoOrigin.host = host;
+ mojoOrigin.port = (short) port;
+ // nonceIfOpaque is left at its default of null, i.e. a tuple (non-opaque) origin.
+ return new Origin(mojoOrigin);
+ }
+}
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page