CVE-2026-13962
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ChromeContentBrowserClientMimeHandlerFilePickerTestchrome/browser/chrome_content_browser_client_unittest.cc |
modified | |
TEST_Fextensions/browser/mime_handler/mime_handler_stream_manager_unittest.cc |
modified |
Files Changed
chrome/browser/chrome_content_browser_client_unittest.ccextensions/browser/mime_handler/mime_handler_stream_manager.ccextensions/browser/mime_handler/mime_handler_stream_manager.hextensions/browser/mime_handler/mime_handler_stream_manager_unittest.cc
Patch
From 52d248ec1b458742821438e2b412042117bf7a18 Mon Sep 17 00:00:00 2001
From: Andy Phan <andyphan@chromium.org>
Date: Fri, 29 May 2026 16:46:28 -0700
Subject: [PATCH] [mime_handler] Verify embedder URL in MimeHandlerStreamManager
Verify that the embedder frame's last committed URL matches the original
PDF URL in IsExtensionFrameTreeNodeId() and IsContentFrameTreeNodeId().
Previously, these privilege-granting predicates only verified the
FrameTreeNodeId, leaving them vulnerable to same-document URL spoofing
(e.g., via history.pushState) which allowed cross-origin pages to
inherit PDF extension privileges.
This CL updates the predicates to compare the URLs while ignoring
fragment/hash differences using GetWithoutRef(), preventing spoofing
while ensuring legitimate hash changes (e.g., scrolling) do not break
the viewer.
Also update ChromeContentBrowserClientMimeHandlerFilePickerTest unit
tests to commit the matching original stream URL, satisfying the new
browser-process URL verification checks.
Bug: 513248926, 513721370
Change-Id: I47ceaefddc208f16aa226f401b30d6036aac77fc
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7884085
Commit-Queue: Andy Phan <andyphan@chromium.org>
Reviewed-by: Lei Zhang <thestig@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1638817}
---
diff --git a/chrome/browser/chrome_content_browser_client_unittest.cc b/chrome/browser/chrome_content_browser_client_unittest.cc
index 8650874..bfe1d23 100644
--- a/chrome/browser/chrome_content_browser_client_unittest.cc
+++ b/chrome/browser/chrome_content_browser_client_unittest.cc
@@ -2283,9 +2283,6 @@
#endif // BUILDFLAG(ENABLE_PDF)
#if BUILDFLAG(ENABLE_EXTENSIONS) && !BUILDFLAG(IS_ANDROID)
-constexpr char kMimeHandlerViewerUrl[] =
- "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/viewer.html";
-
class ChromeContentBrowserClientMimeHandlerFilePickerTest
: public ChromeRenderViewHostTestHarness {
public:
@@ -2293,6 +2290,10 @@
~ChromeContentBrowserClientMimeHandlerFilePickerTest() override = default;
protected:
+ static constexpr char kMimeHandlerViewerUrl[] =
+ "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/viewer.html";
+ static constexpr char kOriginalUrl[] = "https://original_url1";
+
content::RenderFrameHost* CreateChild(content::RenderFrameHost* parent,
const std::string& name) {
auto* parent_tester = content::RenderFrameHostTester::For(parent);
@@ -2333,7 +2334,7 @@
// file picker.
TEST_F(ChromeContentBrowserClientMimeHandlerFilePickerTest,
FullPageMimeHandlerExtensionFrameAllowed) {
- NavigateAndCommit(GURL("https://example.test/document"));
+ NavigateAndCommit(GURL(kOriginalUrl));
content::RenderFrameHost* extension_host =
CreateMimeHandlerExtensionHost(main_rfh(), GURL(kMimeHandlerViewerUrl));
@@ -2351,7 +2352,7 @@
content::RenderFrameHost* embedder_host =
CreateChild(main_rfh(), "embedded-mime-handler");
embedder_host = content::NavigationSimulator::NavigateAndCommitFromDocument(
- GURL("https://embedder.test/file.foo"), embedder_host);
+ GURL(kOriginalUrl), embedder_host);
content::RenderFrameHost* extension_host = CreateMimeHandlerExtensionHost(
embedder_host, GURL(kMimeHandlerViewerUrl));
@@ -2366,7 +2367,7 @@
// extension origin, is allowed to show a file picker.
TEST_F(ChromeContentBrowserClientMimeHandlerFilePickerTest,
DescendantOfMimeHandlerExtensionFrameAllowed) {
- NavigateAndCommit(GURL("https://example.test/document"));
+ NavigateAndCommit(GURL(kOriginalUrl));
content::RenderFrameHost* extension_host =
CreateMimeHandlerExtensionHost(main_rfh(), GURL(kMimeHandlerViewerUrl));
content::RenderFrameHost* descendant =
@@ -2400,7 +2401,7 @@
// extension host's committed origin is denied a file picker.
TEST_F(ChromeContentBrowserClientMimeHandlerFilePickerTest,
MimeHandlerExtensionFrameWithMismatchedOriginDenied) {
- NavigateAndCommit(GURL("https://example.test/document"));
+ NavigateAndCommit(GURL(kOriginalUrl));
content::RenderFrameHost* extension_host =
CreateMimeHandlerExtensionHost(main_rfh(), GURL(kMimeHandlerViewerUrl));
diff --git a/extensions/browser/mime_handler/mime_handler_stream_manager.cc b/extensions/browser/mime_handler/mime_handler_stream_manager.cc
index 3dc09aa..fab27a2f 100644
--- a/extensions/browser/mime_handler/mime_handler_stream_manager.cc
+++ b/extensions/browser/mime_handler/mime_handler_stream_manager.cc
@@ -266,7 +266,10 @@
content::FrameTreeNodeId frame_tree_node_id) const {
const auto* stream_info = GetClaimedStreamInfo(embedder_host);
return stream_info &&
- frame_tree_node_id == stream_info->extension_host_frame_tree_node_id();
+ frame_tree_node_id ==
+ stream_info->extension_host_frame_tree_node_id() &&
+ embedder_host->GetLastCommittedURL().EqualsIgnoringRef(
+ stream_info->stream()->original_url());
}
bool MimeHandlerStreamManager::DidExtensionFrameFinishNavigation(
@@ -300,7 +303,9 @@
content::FrameTreeNodeId frame_tree_node_id) const {
const auto* stream_info = GetClaimedStreamInfo(embedder_host);
return stream_info &&
- frame_tree_node_id == stream_info->content_host_frame_tree_node_id();
+ frame_tree_node_id == stream_info->content_host_frame_tree_node_id() &&
+ embedder_host->GetLastCommittedURL().EqualsIgnoringRef(
+ stream_info->stream()->original_url());
}
bool MimeHandlerStreamManager::DidContentFrameFinishNavigation(
diff --git a/extensions/browser/mime_handler/mime_handler_stream_manager.h b/extensions/browser/mime_handler/mime_handler_stream_manager.h
index 1f4a5abf..11a8d33 100644
--- a/extensions/browser/mime_handler/mime_handler_stream_manager.h
+++ b/extensions/browser/mime_handler/mime_handler_stream_manager.h
@@ -141,7 +141,8 @@
bool IsExtensionHost(const content::RenderFrameHost* render_frame_host) const;
// Returns true if `frame_tree_node_id` is the frame tree node ID for the
- // extension frame under `embedder_host`, false otherwise.
+ // extension frame under `embedder_host` and the `embedder_host`'s last
+ // committed URL matches the stream's original URL, false otherwise.
bool IsExtensionFrameTreeNodeId(
const content::RenderFrameHost* embedder_host,
content::FrameTreeNodeId frame_tree_node_id) const;
@@ -160,7 +161,8 @@
bool IsContentHost(const content::RenderFrameHost* render_frame_host) const;
// Returns true if `frame_tree_node_id` is the frame tree node ID for the
- // content frame under `embedder_host`, false otherwise.
+ // content frame under `embedder_host` and the `embedder_host`'s last
+ // committed URL matches the stream's original URL, false otherwise.
bool IsContentFrameTreeNodeId(
const content::RenderFrameHost* embedder_host,
content::FrameTreeNodeId frame_tree_node_id) const;
diff --git a/extensions/browser/mime_handler/mime_handler_stream_manager_unittest.cc b/extensions/browser/mime_handler/mime_handler_stream_manager_unittest.cc
index e1dc62e..7abefee 100644
--- a/extensions/browser/mime_handler/mime_handler_stream_manager_unittest.cc
+++ b/extensions/browser/mime_handler/mime_handler_stream_manager_unittest.cc
@@ -1264,4 +1264,90 @@
EXPECT_FALSE(manager->GetTopLevelHandlerExtensionId().has_value());
}
+// MimeHandlerStreamManager host privilege status should be correctly revoked
+// during a pushState URL path spoofing same-document navigation.
+TEST_F(MimeHandlerStreamManagerTest, HostPrivilegeBypassWithPushState) {
+ const GURL pdf_url(kOriginalUrl1);
+
+ content::RenderFrameHost* embedder_host =
+ NavigateAndCommit(main_rfh(), pdf_url);
+ auto* extension_host =
+ CreateChildRenderFrameHost(embedder_host, "extension host");
+ auto* content_host =
+ CreateChildRenderFrameHost(extension_host, "content host");
+ content_host = NavigateAndCommit(content_host, pdf_url);
+
+ MimeHandlerStreamManager* manager = mime_handler_stream_manager();
+ manager->AddStreamContainer(
+ embedder_host->GetFrameTreeNodeId(), "internal_id",
+ extensions::mime_handler::GenerateSampleStreamContainer(1),
+ std::make_unique<NiceMock<MockMimeHandlerStreamDelegate>>());
+ manager->ClaimStreamInfoForTesting(embedder_host);
+ manager->SetExtensionFrameTreeNodeIdForTesting(
+ embedder_host, extension_host->GetFrameTreeNodeId());
+ manager->SetContentFrameTreeNodeIdForTesting(
+ embedder_host, content_host->GetFrameTreeNodeId());
+
+ content::OverrideLastCommittedOrigin(
+ extension_host,
+ url::Origin::Create(GURL(
+ "chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/index.html")));
+
+ // Initially, both extension and content frames should have host privileges.
+ EXPECT_TRUE(manager->IsExtensionHost(extension_host));
+ EXPECT_TRUE(manager->IsContentHost(content_host));
+
+ // Simulate pushState URL spoofing on the embedder host.
+ auto simulator = content::NavigationSimulator::CreateRendererInitiated(
+ GURL("https://original_url1/spoofed"), embedder_host);
+ simulator->CommitSameDocument();
+
+ // Spoofed URL path must revoke the privileges.
+ EXPECT_FALSE(manager->IsExtensionHost(extension_host));
+ EXPECT_FALSE(manager->IsContentHost(content_host));
+}
+
+// MimeHandlerStreamManager should continue to identify the extension and
+// content frames during same-document fragment/hash navigations on the
+// embedder host (e.g., scrolling).
+TEST_F(MimeHandlerStreamManagerTest, AllowsFragmentNavigation) {
+ const GURL pdf_url(kOriginalUrl1);
+
+ content::RenderFrameHost* embedder_host =
Regression Test / PoC
diff --git a/chrome/browser/chrome_content_browser_client_unittest.cc b/chrome/browser/chrome_content_browser_client_unittest.cc
index 8650874..bfe1d23 100644
--- a/chrome/browser/chrome_content_browser_client_unittest.cc
+++ b/chrome/browser/chrome_content_browser_client_unittest.cc
@@ -2283,9 +2283,6 @@
#endif // BUILDFLAG(ENABLE_PDF)
#if BUILDFLAG(ENABLE_EXTENSIONS) && !BUILDFLAG(IS_ANDROID)
-constexpr char kMimeHandlerViewerUrl[] =
- "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/viewer.html";
-
class ChromeContentBrowserClientMimeHandlerFilePickerTest
: public ChromeRenderViewHostTestHarness {
public:
@@ -2293,6 +2290,10 @@
~ChromeContentBrowserClientMimeHandlerFilePickerTest() override = default;
protected:
+ static constexpr char kMimeHandlerViewerUrl[] =
+ "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/viewer.html";
+ static constexpr char kOriginalUrl[] = "https://original_url1";
+
content::RenderFrameHost* CreateChild(content::RenderFrameHost* parent,
const std::string& name) {
auto* parent_tester = content::RenderFrameHostTester::For(parent);
@@ -2333,7 +2334,7 @@
// file picker.
TEST_F(ChromeContentBrowserClientMimeHandlerFilePickerTest,
FullPageMimeHandlerExtensionFrameAllowed) {
- NavigateAndCommit(GURL("https://example.test/document"));
+ NavigateAndCommit(GURL(kOriginalUrl));
content::RenderFrameHost* extension_host =
CreateMimeHandlerExtensionHost(main_rfh(), GURL(kMimeHandlerViewerUrl));
@@ -2351,7 +2352,7 @@
content::RenderFrameHost* embedder_host =
CreateChild(main_rfh(), "embedded-mime-handler");
embedder_host = content::NavigationSimulator::NavigateAndCommitFromDocument(
- GURL("https://embedder.test/file.foo"), embedder_host);
+ GURL(kOriginalUrl), embedder_host);
content::RenderFrameHost* extension_host = CreateMimeHandlerExtensionHost(
embedder_host, GURL(kMimeHandlerViewerUrl));
@@ -2366,7 +2367,7 @@
// extension origin, is allowed to show a file picker.
TEST_F(ChromeContentBrowserClientMimeHandlerFilePickerTest,
DescendantOfMimeHandlerExtensionFrameAllowed) {
- NavigateAndCommit(GURL("https://example.test/document"));
+ NavigateAndCommit(GURL(kOriginalUrl));
content::RenderFrameHost* extension_host =
CreateMimeHandlerExtensionHost(main_rfh(), GURL(kMimeHandlerViewerUrl));
content::RenderFrameHost* descendant =
@@ -2400,7 +2401,7 @@
// extension host's committed origin is denied a file picker.
TEST_F(ChromeContentBrowserClientMimeHandlerFilePickerTest,
MimeHandlerExtensionFrameWithMismatchedOriginDenied) {
- NavigateAndCommit(GURL("https://example.test/document"));
+ NavigateAndCommit(GURL(kOriginalUrl));
content::RenderFrameHost* extension_host =
CreateMimeHandlerExtensionHost(main_rfh(), GURL(kMimeHandlerViewerUrl));
diff --git a/extensions/browser/mime_handler/mime_handler_stream_manager_unittest.cc b/extensions/browser/mime_handler/mime_handler_stream_manager_unittest.cc
index e1dc62e..7abefee 100644
--- a/extensions/browser/mime_handler/mime_handler_stream_manager_unittest.cc
+++ b/extensions/browser/mime_handler/mime_handler_stream_manager_unittest.cc
@@ -1264,4 +1264,90 @@
EXPECT_FALSE(manager->GetTopLevelHandlerExtensionId().has_value());
}
+// MimeHandlerStreamManager host privilege status should be correctly revoked
+// during a pushState URL path spoofing same-document navigation.
+TEST_F(MimeHandlerStreamManagerTest, HostPrivilegeBypassWithPushState) {
+ const GURL pdf_url(kOriginalUrl1);
+
+ content::RenderFrameHost* embedder_host =
+ NavigateAndCommit(main_rfh(), pdf_url);
+ auto* extension_host =
+ CreateChildRenderFrameHost(embedder_host, "extension host");
+ auto* content_host =
+ CreateChildRenderFrameHost(extension_host, "content host");
+ content_host = NavigateAndCommit(content_host, pdf_url);
+
+ MimeHandlerStreamManager* manager = mime_handler_stream_manager();
+ manager->AddStreamContainer(
+ embedder_host->GetFrameTreeNodeId(), "internal_id",
+ extensions::mime_handler::GenerateSampleStreamContainer(1),
+ std::make_unique<NiceMock<MockMimeHandlerStreamDelegate>>());
+ manager->ClaimStreamInfoForTesting(embedder_host);
+ manager->SetExtensionFrameTreeNodeIdForTesting(
+ embedder_host, extension_host->GetFrameTreeNodeId());
+ manager->SetContentFrameTreeNodeIdForTesting(
+ embedder_host, content_host->GetFrameTreeNodeId());
+
+ content::OverrideLastCommittedOrigin(
+ extension_host,
+ url::Origin::Create(GURL(
+ "chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/index.html")));
+
+ // Initially, both extension and content frames should have host privileges.
+ EXPECT_TRUE(manager->IsExtensionHost(extension_host));
+ EXPECT_TRUE(manager->IsContentHost(content_host));
+
+ // Simulate pushState URL spoofing on the embedder host.
+ auto simulator = content::NavigationSimulator::CreateRendererInitiated(
+ GURL("https://original_url1/spoofed"), embedder_host);
+ simulator->CommitSameDocument();
+
+ // Spoofed URL path must revoke the privileges.
+ EXPECT_FALSE(manager->IsExtensionHost(extension_host));
+ EXPECT_FALSE(manager->IsContentHost(content_host));
+}
+
+// MimeHandlerStreamManager should continue to identify the extension and
+// content frames during same-document fragment/hash navigations on the
+// embedder host (e.g., scrolling).
+TEST_F(MimeHandlerStreamManagerTest, AllowsFragmentNavigation) {
+ const GURL pdf_url(kOriginalUrl1);
+
+ content::RenderFrameHost* embedder_host =
+ NavigateAndCommit(main_rfh(), pdf_url);
+ auto* extension_host =
+ CreateChildRenderFrameHost(embedder_host, "extension host");
+ auto* content_host =
+ CreateChildRenderFrameHost(extension_host, "content host");
+ content_host = NavigateAndCommit(content_host, pdf_url);
+
+ MimeHandlerStreamManager* manager = mime_handler_stream_manager();
+ manager->AddStreamContainer(
+ embedder_host->GetFrameTreeNodeId(), "internal_id",
+ extensions::mime_handler::GenerateSampleStreamContainer(1),
+ std::make_unique<NiceMock<MockMimeHandlerStreamDelegate>>());
+ manager->ClaimStreamInfoForTesting(embedder_host);
+ manager->SetExtensionFrameTreeNodeIdForTesting(
+ embedder_host, extension_host->GetFrameTreeNodeId());
+ manager->SetContentFrameTreeNodeIdForTesting(
+ embedder_host, content_host->GetFrameTreeNodeId());
+
+ content::OverrideLastCommittedOrigin(
+ extension_host,
+ url::Origin::Create(GURL(
+ "chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/index.html")));
+
+ EXPECT_TRUE(manager->IsExtensionHost(extension_host));
+ EXPECT_TRUE(manager->IsContentHost(content_host));
+
+ // Simulate a fragment/hash navigation on the embedder host (e.g. scrolling
+ // updates hash).
+ auto simulator = content::NavigationSimulator::CreateRendererInitiated(
+ GURL("https://original_url1#page=2"), embedder_host);
+ simulator->CommitSameDocument();
+
+ EXPECT_TRUE(manager->IsExtensionHost(extension_host));
+ EXPECT_TRUE(manager->IsContentHost(content_host));
+}
+
} // namespace extensions::mime_handler
Original Bug Report
Bypass of JavaScript content settings and cookie partitioning in OOPIF-PDF
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 logic desynchronization in how the browser identifies privileged PDF frames allows a compromised renderer to bypass JavaScript content settings and cookie partitioning. By using history.pushState to desynchronize an embedder’s URL, an attacker can bypass navigation throttles while retaining privileged status for a cross-origin victim site. This results in the victim site executing JavaScript despite blocks and accessing partitioned cookies.
Affected files:
chrome/browser/content_settings/page_specific_content_settings_delegate.ccextensions/browser/mime_handler/mime_handler_stream_manager.ccchrome/browser/pdf/chrome_pdf_stream_delegate.cccomponents/pdf/browser/pdf_navigation_throttle.ccchrome/browser/chrome_content_browser_client.cccomponents/content_settings/browser/page_specific_content_settings.cc
Estimated timestamp from git blame: 2024-03-19
Summary
A potential vulnerability exists in the OOPIF-PDF (Out-of-Process Iframe PDF) viewer where the browser process’s predicates for identifying privileged frames fail to verify the embedder’s URL. An attacker with a compromised renderer can exploit this to bypass ‘Block JavaScript’ content settings and cookie partitioning protections (like CHIPS and SameSite=Lax/Strict) for arbitrary origins.
Root Cause Analysis
The OOPIF-PDF architecture uses a three-level frame hierarchy: Embedder Host (web page), Extension Host (chrome-extension://...), and Content Host (the plugin frame). The MimeHandlerStreamManager manages the metadata (StreamInfo) for these frames.
There is a discrepancy in how the browser validates these frames:
- Navigation Throttling:
PdfNavigationThrottle(viaChromePdfStreamDelegate::ShouldAllowPdfFrameNavigation) callsMimeHandlerStreamManager::GetStreamContainer. This function correctly verifies that the embedder’s last committed URL matches the original PDF URL (extensions/browser/mime_handler/mime_handler_stream_manager.cc:223). - Privilege Granting: Predicates like
IsExtensionHostandIsContentHost(used byPageSpecificContentSettingsandChromeContentBrowserClient) rely onGetClaimedStreamInfo. This function performs a lookup using only theFrameTreeNodeIdandGlobalRenderFrameHostId, omitting the URL validation check (extensions/browser/mime_handler/mime_handler_stream_manager.cc:665).
By using history.pushState in the embedder frame, an attacker can cause the URL check in GetStreamContainer to fail, leading the navigation throttle to permit a cross-origin navigation of the extension frame to a victim site. However, during the ReadyToCommit stage, the privilege-granting predicates still return true, granting the victim site the privileges intended for the PDF viewer.
Potential Exploitation Path
- A compromised renderer at
https://attacker.comloads a PDF in an iframe. The browser registers aStreamInfofor the resulting extension frame. - The attacker executes
history.pushState({}, '', '/spoofed')in the top-level (embedder) frame. This updates the frame’s last committed URL without changing its ID. - The attacker triggers a navigation of the child PDF extension frame to
https://victim.com. PdfNavigationThrottleintercepts the navigation. Because the embedder’s URL (/spoofed) no longer matches the PDF’s original URL,GetStreamContainerreturnsnullptr. The throttle assumes the frame is no longer an active PDF viewer and permits the navigation.- Upon commit of
https://victim.com, the browser callsIsFrameAllowlistedForJavaScript. Because this check does not verify the embedder’s URL, it returnstruebased on the persistentFrameTreeNodeIdmapping. This setsallow_script = truein the navigation parameters, bypassing any JavaScript block settings forvictim.com. - Similarly,
GetEffectiveTopFrameForPartitioningis called, which incorrectly identifies the victim frame as a top-level frame, bypassing cookie partitioning (SameSite/CHIPS) for the victim origin.
Impact
An attacker can force JavaScript execution on origins where the user has explicitly disabled it. Furthermore, it allows an attacker to bypass storage isolation and partitioning, potentially enabling CSRF or information leakage against sites relying on CHIPS or SameSite cookie attributes.
Suggested Fix
The predicates MimeHandlerStreamManager::IsExtensionHost, IsContentHost, and their underlying Is...FrameTreeNodeId helpers should be updated to verify that the embedder_host’s last committed URL matches the original_url stored in the StreamInfo, consistent with the verification performed in GetStreamContainer.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.