CVE-2026-16415
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
MimeHandlerFallbackRedirectBrowserTestchrome/browser/extensions/api/mime_handlers/mime_handler_fallback_browsertest.cc |
modified | |
ifchrome/browser/plugins/plugin_response_interceptor_url_loader_throttle.cc |
modified |
Files Changed
chrome/browser/extensions/api/mime_handlers/mime_handler_fallback_browsertest.ccchrome/browser/plugins/plugin_response_interceptor_url_loader_throttle.cc
Patch
From 88ac5b42a6b20f104ecf913dfb970a2b0abd2b4a Mon Sep 17 00:00:00 2001
From: Andy Phan <andyphan@chromium.org>
Date: Fri, 10 Jul 2026 15:37:39 -0700
Subject: [PATCH] [mime_handler] Validate URL during MIME handler fallback re-navigation
When a generic MIME handler (such as a 3p extension) aborts and triggers
fallback, it initiates a reload of the embedder frame. The response body
of the initial request is cached and later spliced into the reload
response by PluginResponseInterceptorURLLoaderThrottle.
Previously, this lookup was keyed only by the FrameTreeNodeId. If the
reload was redirected (e.g. HTTP 302) to a different origin by the
attacker's server, the attacker-controlled cached body could be spliced
under the victim's URL, leading to address bar spoofing.
Fix the issue by storing the original URL for which the fallback body
was cached. When WillProcessResponse is called, the throttle now passes
the response URL, and the manager validates that it matches (ignoring
ref) the cached original URL. If they mismatch, the cached body is
discarded and the reload falls back to the network.
Bug: 519244446
Change-Id: I21a70d101dbcbe5878e80bcda7b6316fc579fe35
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8031969
Reviewed-by: Devlin Cronin <rdevlin.cronin@chromium.org>
Reviewed-by: Lei Zhang <thestig@chromium.org>
Commit-Queue: Andy Phan <andyphan@chromium.org>
Reviewed-by: Maksim Sisov <msisov@igalia.com>
Cr-Commit-Position: refs/heads/main@{#1660567}
---
diff --git a/chrome/browser/extensions/api/mime_handlers/mime_handler_fallback_browsertest.cc b/chrome/browser/extensions/api/mime_handlers/mime_handler_fallback_browsertest.cc
index c1da100..d4ac4ec 100644
--- a/chrome/browser/extensions/api/mime_handlers/mime_handler_fallback_browsertest.cc
+++ b/chrome/browser/extensions/api/mime_handlers/mime_handler_fallback_browsertest.cc
@@ -6,10 +6,12 @@
#include <string_view>
#include "base/files/file_path.h"
+#include "base/files/file_util.h"
#include "base/path_service.h"
#include "base/strings/strcat.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/with_feature_override.h"
+#include "base/threading/thread_restrictions.h"
#include "chrome/browser/extensions/extension_apitest.h"
#include "chrome/browser/pdf/pdf_extension_test_util.h"
#include "chrome/browser/ui/browser.h"
@@ -27,6 +29,7 @@
#include "extensions/common/extension_features.h"
#include "extensions/common/features/feature_channel.h"
#include "net/dns/mock_host_resolver.h"
+#include "net/test/embedded_test_server/controllable_http_response.h"
#include "pdf/pdf_features.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "url/gurl.h"
@@ -318,6 +321,119 @@
EXPECT_EQ(pdf_url, web_contents->GetLastCommittedURL());
}
+class MimeHandlerFallbackRedirectBrowserTest
+ : public MimeHandlerFallbackBrowserTest {
+ public:
+ MimeHandlerFallbackRedirectBrowserTest() = default;
+
+ protected:
+ void SetUpOnMainThread() override {
+ const base::FilePath chrome_test_data_dir =
+ base::PathService::CheckedGet(chrome::DIR_TEST_DATA);
+ const base::FilePath pdf_path =
+ chrome_test_data_dir.AppendASCII("pdf").AppendASCII("test.pdf");
+ ASSERT_TRUE(base::PathExists(pdf_path));
+ {
+ base::ScopedAllowBlockingForTesting allow_blocking;
+ ASSERT_TRUE(base::ReadFileToString(pdf_path, &pdf_data_));
+ }
+
+ // Register controllable responses before starting the server. Expect two
+ // requests to "/spoof.pdf".
+ controllable_response1_ =
+ std::make_unique<net::test_server::ControllableHttpResponse>(
+ embedded_test_server(), "/spoof.pdf");
+ controllable_response2_ =
+ std::make_unique<net::test_server::ControllableHttpResponse>(
+ embedded_test_server(), "/spoof.pdf");
+
+ MimeHandlerFallbackBrowserTest::SetUpOnMainThread();
+ }
+
+ const std::string& pdf_data() const { return pdf_data_; }
+
+ net::test_server::ControllableHttpResponse* controllable_response1() {
+ return controllable_response1_.get();
+ }
+
+ net::test_server::ControllableHttpResponse* controllable_response2() {
+ return controllable_response2_.get();
+ }
+
+ private:
+ std::string pdf_data_;
+ std::unique_ptr<net::test_server::ControllableHttpResponse>
+ controllable_response1_;
+ std::unique_ptr<net::test_server::ControllableHttpResponse>
+ controllable_response2_;
+};
+
+// Verify that fallback reload redirected to a different origin does not use the
+// cached body, preventing spoofing.
+IN_PROC_BROWSER_TEST_P(MimeHandlerFallbackRedirectBrowserTest,
+ FallbackRedirectToDifferentOriginDoesNotUseCache) {
+ if (!chrome_pdf::features::IsOopifPdfEnabled()) {
+ GTEST_SKIP() << "Cached-body fallback routing is OOPIF-only.";
+ }
+
+ ASSERT_NO_FATAL_FAILURE(LoadThirdPartyHandler());
+ content::WebContents* const web_contents =
+ browser()->tab_strip_model()->GetActiveWebContents();
+
+ const GURL start_url = embedded_test_server()->GetURL("a.com", "/spoof.pdf");
+
+ const auto pdf_extension_observer = MakePdfExtensionObserver();
+
+ // Start navigation (non-blocking).
+ web_contents->GetController().LoadURLWithParams(
+ content::NavigationController::LoadURLParams(start_url));
+
+ // 1. Handle first request (attacker PDF).
+ net::test_server::ControllableHttpResponse* response1 =
+ controllable_response1();
+ response1->WaitForRequest();
+ response1->Send("HTTP/1.1 200 OK\r\n");
+ response1->Send("Content-Type: application/pdf\r\n");
+ response1->Send("\r\n");
+ response1->Send(pdf_data());
+ response1->Done();
+
+ // 2. Handle second request (fallback reload redirected to b.com).
+ net::test_server::ControllableHttpResponse* response2 =
+ controllable_response2();
+ response2->WaitForRequest();
+ const GURL target_url =
+ embedded_test_server()->GetURL("b.com", "/accessibility/multi-page.pdf");
+ response2->Send("HTTP/1.1 302 Found\r\n");
+ response2->Send("Location: " + target_url.spec() + "\r\n");
+ response2->Send("\r\n");
+ response2->Done();
+
+ // Now wait for the navigation to settle.
+ pdf_extension_observer->WaitForNavigationFinished();
+ ASSERT_TRUE(content::WaitForLoadStop(web_contents));
+
+ content::RenderFrameHost* const extension_host =
+ pdf_extension_test_util::GetOnlyPdfExtensionHost(web_contents);
+ ASSERT_TRUE(extension_host);
+
+ // Verify that the PDF has loaded.
+ EXPECT_TRUE(pdf_extension_test_util::EnsurePDFHasLoaded(web_contents));
+
+ // Get the page count.
+ const int page_count =
+ content::EvalJs(extension_host, "viewer.docLength_").ExtractInt();
+
+ // test.pdf has 1 page. accessibility/multi-page.pdf has 2 pages. Expect the
+ // browser to load the redirected PDF (multi-page, 2 pages). If spoofing
+ // occurs, the browser loads the cached PDF (test.pdf, 1 page).
+ EXPECT_EQ(2, page_count);
+
+ // Also verify the committed URL is the redirected one.
+ EXPECT_EQ(target_url, web_contents->GetLastCommittedURL());
+}
+
INSTANTIATE_FEATURE_OVERRIDE_TEST_SUITE(MimeHandlerFallbackBrowserTest);
+INSTANTIATE_FEATURE_OVERRIDE_TEST_SUITE(MimeHandlerFallbackRedirectBrowserTest);
} // namespace extensions
diff --git a/chrome/browser/plugins/plugin_response_interceptor_url_loader_throttle.cc b/chrome/browser/plugins/plugin_response_interceptor_url_loader_throttle.cc
index 40d2176..7b53b59 100644
--- a/chrome/browser/plugins/plugin_response_interceptor_url_loader_throttle.cc
+++ b/chrome/browser/plugins/plugin_response_interceptor_url_loader_throttle.cc
@@ -167,25 +167,24 @@
if (response_head->mime_type == pdf::kPDFMimeType) {
// A generic MIME handler extension called
// chrome.mimeHandler.abortAndFallbackToNativeHandler() on a prior
- // navigation for this embedder frame. Peek (not consume) at the
- // fallback mark so the aborted extension does not re-claim its own
- // response across the whole redirect chain -- the mark is cleared in
- // `DidFinishNavigation()` once the re-fetch settles. Route the
- // application/pdf response to the user agent's built-in PDF viewer.
- // When the prior stream buffered the response body, take it now and
- // replay it below instead of re-reading the reload's network body.
- // The cached body is consumable only by the OOPIF PDF stream
- // pipeline; the legacy MimeHandlerView GuestView path has no hook
- // for a pre-fetched body pipe, so leave the pipe parked and let the
- // reload re-fetch from the network.
+ // navigation for this embedder frame. Peek (not consume) at the fallback
+ // mark so the aborted extension does not re-claim its own response on
+ // reload -- the mark is cleared in `DidFinishNavigation()` once the
+ // re-fetch settles. Route the application/pdf response to the user agent's
+ // built-in PDF viewer. When the prior stream buffered the response body,
Regression Test / PoC
diff --git a/chrome/browser/extensions/api/mime_handlers/mime_handler_fallback_browsertest.cc b/chrome/browser/extensions/api/mime_handlers/mime_handler_fallback_browsertest.cc
index c1da100..d4ac4ec 100644
--- a/chrome/browser/extensions/api/mime_handlers/mime_handler_fallback_browsertest.cc
+++ b/chrome/browser/extensions/api/mime_handlers/mime_handler_fallback_browsertest.cc
@@ -6,10 +6,12 @@
#include <string_view>
#include "base/files/file_path.h"
+#include "base/files/file_util.h"
#include "base/path_service.h"
#include "base/strings/strcat.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/with_feature_override.h"
+#include "base/threading/thread_restrictions.h"
#include "chrome/browser/extensions/extension_apitest.h"
#include "chrome/browser/pdf/pdf_extension_test_util.h"
#include "chrome/browser/ui/browser.h"
@@ -27,6 +29,7 @@
#include "extensions/common/extension_features.h"
#include "extensions/common/features/feature_channel.h"
#include "net/dns/mock_host_resolver.h"
+#include "net/test/embedded_test_server/controllable_http_response.h"
#include "pdf/pdf_features.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "url/gurl.h"
@@ -318,6 +321,119 @@
EXPECT_EQ(pdf_url, web_contents->GetLastCommittedURL());
}
+class MimeHandlerFallbackRedirectBrowserTest
+ : public MimeHandlerFallbackBrowserTest {
+ public:
+ MimeHandlerFallbackRedirectBrowserTest() = default;
+
+ protected:
+ void SetUpOnMainThread() override {
+ const base::FilePath chrome_test_data_dir =
+ base::PathService::CheckedGet(chrome::DIR_TEST_DATA);
+ const base::FilePath pdf_path =
+ chrome_test_data_dir.AppendASCII("pdf").AppendASCII("test.pdf");
+ ASSERT_TRUE(base::PathExists(pdf_path));
+ {
+ base::ScopedAllowBlockingForTesting allow_blocking;
+ ASSERT_TRUE(base::ReadFileToString(pdf_path, &pdf_data_));
+ }
+
+ // Register controllable responses before starting the server. Expect two
+ // requests to "/spoof.pdf".
+ controllable_response1_ =
+ std::make_unique<net::test_server::ControllableHttpResponse>(
+ embedded_test_server(), "/spoof.pdf");
+ controllable_response2_ =
+ std::make_unique<net::test_server::ControllableHttpResponse>(
+ embedded_test_server(), "/spoof.pdf");
+
+ MimeHandlerFallbackBrowserTest::SetUpOnMainThread();
+ }
+
+ const std::string& pdf_data() const { return pdf_data_; }
+
+ net::test_server::ControllableHttpResponse* controllable_response1() {
+ return controllable_response1_.get();
+ }
+
+ net::test_server::ControllableHttpResponse* controllable_response2() {
+ return controllable_response2_.get();
+ }
+
+ private:
+ std::string pdf_data_;
+ std::unique_ptr<net::test_server::ControllableHttpResponse>
+ controllable_response1_;
+ std::unique_ptr<net::test_server::ControllableHttpResponse>
+ controllable_response2_;
+};
+
+// Verify that fallback reload redirected to a different origin does not use the
+// cached body, preventing spoofing.
+IN_PROC_BROWSER_TEST_P(MimeHandlerFallbackRedirectBrowserTest,
+ FallbackRedirectToDifferentOriginDoesNotUseCache) {
+ if (!chrome_pdf::features::IsOopifPdfEnabled()) {
+ GTEST_SKIP() << "Cached-body fallback routing is OOPIF-only.";
+ }
+
+ ASSERT_NO_FATAL_FAILURE(LoadThirdPartyHandler());
+ content::WebContents* const web_contents =
+ browser()->tab_strip_model()->GetActiveWebContents();
+
+ const GURL start_url = embedded_test_server()->GetURL("a.com", "/spoof.pdf");
+
+ const auto pdf_extension_observer = MakePdfExtensionObserver();
+
+ // Start navigation (non-blocking).
+ web_contents->GetController().LoadURLWithParams(
+ content::NavigationController::LoadURLParams(start_url));
+
+ // 1. Handle first request (attacker PDF).
+ net::test_server::ControllableHttpResponse* response1 =
+ controllable_response1();
+ response1->WaitForRequest();
+ response1->Send("HTTP/1.1 200 OK\r\n");
+ response1->Send("Content-Type: application/pdf\r\n");
+ response1->Send("\r\n");
+ response1->Send(pdf_data());
+ response1->Done();
+
+ // 2. Handle second request (fallback reload redirected to b.com).
+ net::test_server::ControllableHttpResponse* response2 =
+ controllable_response2();
+ response2->WaitForRequest();
+ const GURL target_url =
+ embedded_test_server()->GetURL("b.com", "/accessibility/multi-page.pdf");
+ response2->Send("HTTP/1.1 302 Found\r\n");
+ response2->Send("Location: " + target_url.spec() + "\r\n");
+ response2->Send("\r\n");
+ response2->Done();
+
+ // Now wait for the navigation to settle.
+ pdf_extension_observer->WaitForNavigationFinished();
+ ASSERT_TRUE(content::WaitForLoadStop(web_contents));
+
+ content::RenderFrameHost* const extension_host =
+ pdf_extension_test_util::GetOnlyPdfExtensionHost(web_contents);
+ ASSERT_TRUE(extension_host);
+
+ // Verify that the PDF has loaded.
+ EXPECT_TRUE(pdf_extension_test_util::EnsurePDFHasLoaded(web_contents));
+
+ // Get the page count.
+ const int page_count =
+ content::EvalJs(extension_host, "viewer.docLength_").ExtractInt();
+
+ // test.pdf has 1 page. accessibility/multi-page.pdf has 2 pages. Expect the
+ // browser to load the redirected PDF (multi-page, 2 pages). If spoofing
+ // occurs, the browser loads the cached PDF (test.pdf, 1 page).
+ EXPECT_EQ(2, page_count);
+
+ // Also verify the committed URL is the redirected one.
+ EXPECT_EQ(target_url, web_contents->GetLastCommittedURL());
+}
+
INSTANTIATE_FEATURE_OVERRIDE_TEST_SUITE(MimeHandlerFallbackBrowserTest);
+INSTANTIATE_FEATURE_OVERRIDE_TEST_SUITE(MimeHandlerFallbackRedirectBrowserTest);
} // namespace extensions
diff --git a/extensions/browser/api/mime_handler/mime_handler_api_unittest.cc b/extensions/browser/api/mime_handler/mime_handler_api_unittest.cc
index 229d7b4..329f96b 100644
--- a/extensions/browser/api/mime_handler/mime_handler_api_unittest.cc
+++ b/extensions/browser/api/mime_handler/mime_handler_api_unittest.cc
@@ -268,7 +268,7 @@
stream_info->SetDidExtensionFinishNavigation();
const content::FrameTreeNodeId embedder_ftn = embedder->GetFrameTreeNodeId();
- ASSERT_FALSE(manager->IsPendingNativeFallback(embedder_ftn));
+ ASSERT_FALSE(manager->IsPendingNativeFallback(embedder_ftn, kOriginalUrl));
auto function = base::MakeRefCounted<
MimeHandlerAbortAndFallbackToNativeHandlerFunction>();
@@ -277,7 +277,7 @@
EXPECT_TRUE(
api_test_utils::RunFunction(function.get(), "[]", browser_context()));
EXPECT_TRUE(function->GetError().empty()) << function->GetError();
- EXPECT_TRUE(manager->IsPendingNativeFallback(embedder_ftn));
+ EXPECT_TRUE(manager->IsPendingNativeFallback(embedder_ftn, kOriginalUrl));
}
// Built-in MIME handler extensions (e.g. the PDF viewer) are blocked
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 27e82501f..01f5a43 100644
--- a/extensions/browser/mime_handler/mime_handler_stream_manager_unittest.cc
+++ b/extensions/browser/mime_handler/mime_handler_stream_manager_unittest.cc
@@ -1207,8 +1207,10 @@
TEST_F(MimeHandlerStreamManagerTest,
AbortAndFallbackToNativeHandler_MarksEmbedderFrame) {
+ const GURL pdf_url(kOriginalUrl1);
+
content::RenderFrameHost* embedder_host =
- NavigateAndCommit(main_rfh(), GURL(kOriginalUrl1));
+ NavigateAndCommit(main_rfh(), pdf_url);
const content::FrameTreeNodeId embedder_ftn =
embedder_host->GetFrameTreeNodeId();
auto* manager = mime_handler_stream_manager();
@@ -1225,23 +1227,25 @@
ASSERT_TRUE(stream_info);
stream_info->SetDidExtensionFinishNavigation();
- EXPECT_FALSE(manager->IsPendingNativeFallback(embedder_ftn));
+ EXPECT_FALSE(manager->IsPendingNativeFallback(embedder_ftn, pdf_url));
manager->AbortAndFallbackToNativeHandler(embedder_host);
- // Peek is non-destructive -- the throttle's `WillProcessResponse`
- // may fire multiple times in a single re-navigation (redirect chain),
- // so the mark must survive until the navigation completes.
- EXPECT_TRUE(manager->IsPendingNativeFallback(embedder_ftn));
- EXPECT_TRUE(manager->IsPendingNativeFallback(embedder_ftn));
+ // Peek is non-destructive -- the mark must survive until the navigation
+ // completes.
+ EXPECT_TRUE(manager->IsPendingNativeFallback(embedder_ftn, pdf_url));
+ EXPECT_TRUE(manager->IsPendingNativeFallback(embedder_ftn, pdf_url));
// A different frame is never marked.
- EXPECT_FALSE(manager->IsPendingNativeFallback(content::FrameTreeNodeId()));
+ EXPECT_FALSE(
+ manager->IsPendingNativeFallback(content::FrameTreeNodeId(), pdf_url));
}
TEST_F(MimeHandlerStreamManagerTest,
AbortAndFallbackToNativeHandler_DidFinishNavigationClearsMark) {
+ const GURL pdf_url(kOriginalUrl1);
+
content::RenderFrameHost* embedder_host =
- NavigateAndCommit(main_rfh(), GURL(kOriginalUrl1));
+ NavigateAndCommit(main_rfh(), pdf_url);
const content::FrameTreeNodeId embedder_ftn =
embedder_host->GetFrameTreeNodeId();
auto* manager = mime_handler_stream_manager();
@@ -1255,23 +1259,25 @@
stream_info->SetDidExtensionFinishNavigation();
manager->AbortAndFallbackToNativeHandler(embedder_host);
- ASSERT_TRUE(manager->IsPendingNativeFallback(embedder_ftn));
+ ASSERT_TRUE(manager->IsPendingNativeFallback(embedder_ftn, pdf_url));
// `DidFinishNavigation` on the embedder FTN clears the mark --
// committed or errored, the re-fetch is over.
NiceMock<content::MockNavigationHandle> finish_handle(web_contents());
finish_handle.set_render_frame_host(embedder_host);
manager->DidFinishNavigation(&finish_handle);
- EXPECT_FALSE(manager->IsPendingNativeFallback(embedder_ftn));
+ EXPECT_FALSE(manager->IsPendingNativeFallback(embedder_ftn, pdf_url));
}
TEST_F(MimeHandlerStreamManagerTest,
AbortAndFallbackToNativeHandler_NoBodyCache_TakeReturnsInvalid) {
+ const GURL pdf_url(kOriginalUrl1);
+
// Without a body cache attached, the FTN mark still exists but the
// captured handle is invalid -- the throttle will fall through to a
// network refetch.
content::RenderFrameHost* embedder_host =
- NavigateAndCommit(main_rfh(), GURL(kOriginalUrl1));
+ NavigateAndCommit(main_rfh(), pdf_url);
const content::FrameTreeNodeId embedder_ftn =
embedder_host->GetFrameTreeNodeId();
auto* manager = mime_handler_stream_manager();
@@ -1285,12 +1291,15 @@
stream_info->SetDidExtensionFinishNavigation();
manager->AbortAndFallbackToNativeHandler(embedder_host);
- ASSERT_TRUE(manager->IsPendingNativeFallback(embedder_ftn));
- EXPECT_FALSE(manager->TakeCachedFallbackBody(embedder_ftn).has_value());
+ ASSERT_TRUE(manager->IsPendingNativeFallback(embedder_ftn, pdf_url));
+ EXPECT_FALSE(
+ manager->TakeCachedFallbackBody(embedder_ftn, pdf_url).has_value());
}
TEST_F(MimeHandlerStreamManagerTest,
AbortAndFallbackToNativeHandler_ReplaysCachedBody) {
+ const GURL pdf_url(kOriginalUrl1);
+
// Populate a body cache, attach it to the claimed stream, abort.
// `TakeCachedFallbackBody` must return a valid pipe whose bytes match
// the original body. A second take must return an invalid handle --
@@ -1309,7 +1318,7 @@
ASSERT_TRUE(base::test::RunUntil([&] { return cache->is_complete(); }));
content::RenderFrameHost* embedder_host =
- NavigateAndCommit(main_rfh(), GURL(kOriginalUrl1));
+ NavigateAndCommit(main_rfh(), pdf_url);
const content::FrameTreeNodeId embedder_ftn =
embedder_host->GetFrameTreeNodeId();
auto* manager = mime_handler_stream_manager();
@@ -1324,10 +1333,10 @@
stream_info->SetDidExtensionFinishNavigation();
manager->AbortAndFallbackToNativeHandler(embedder_host);
- ASSERT_TRUE(manager->IsPendingNativeFallback(embedder_ftn));
+ ASSERT_TRUE(manager->IsPendingNativeFallback(embedder_ftn, pdf_url));
std::optional<MimeHandlerStreamManager::CachedFallbackBody> taken =
- manager->TakeCachedFallbackBody(embedder_ftn);
+ manager->TakeCachedFallbackBody(embedder_ftn, pdf_url);
ASSERT_TRUE(taken.has_value());
ASSERT_TRUE(taken->pipe.is_valid());
EXPECT_EQ(std::string_view(kBody).size(), taken->decoded_body_size);
@@ -1338,8 +1347,71 @@
// The mark stays in place until `DidFinishNavigation`/`FrameDeleted`,
// but the body is single-use.
- EXPECT_TRUE(manager->IsPendingNativeFallback(embedder_ftn));
- EXPECT_FALSE(manager->TakeCachedFallbackBody(embedder_ftn).has_value());
+ EXPECT_TRUE(manager->IsPendingNativeFallback(embedder_ftn, pdf_url));
+ EXPECT_FALSE(
+ manager->TakeCachedFallbackBody(embedder_ftn, pdf_url).has_value());
+}
+
+TEST_F(MimeHandlerStreamManagerTest,
+ AbortAndFallbackToNativeHandler_RedirectedResponseUrl) {
+ const GURL pdf_url(kOriginalUrl1);
... (truncated)
Original Bug Report
Address bar spoofing via PDF mime-handler fallback and redirect
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 logical vulnerability in the PDF mime-handler fallback mechanism may allow a potential address bar spoofing attack. By leveraging the abortAndFallbackToNativeHandler API followed by an HTTP 302 redirect during the fallback re-navigation, a third-party extension can potentially render arbitrary PDF content under a victim origin’s URL while keeping the victim’s URL and TLS lock icon intact in the omnibox.
Affected files:
extensions/browser/mime_handler/mime_handler_stream_manager.ccchrome/browser/plugins/plugin_response_interceptor_url_loader_throttle.cc
Estimated timestamp from git blame: 2026-05-13
Description
A potential logic flaw exists in the PDF mime-handler fallback mechanism that can result in a full address bar URL spoof. When a third-party extension handling PDF files triggers a fallback using the chrome.mimeHandler.abortAndFallbackToNativeHandler() API, the browser-side MimeHandlerStreamManager caches the stream’s data pipe and initiates a reload navigation of the embedder frame.
However, the fallback registry (pending_native_fallback_frames_) is keyed only by FrameTreeNodeId, and no validation is performed to ensure the redirected URL matches the original URL for which the fallback body was cached. If the reload navigation is redirected via an HTTP 302 response to a victim’s URL, the PluginResponseInterceptorURLLoaderThrottle will retrieve the attacker’s pre-buffered body pipe and splice it directly into the victim’s response. Consequently, Chrome’s built-in PDF viewer will render the attacker’s PDF payload under the victim’s committed URL context.
Potential Trigger Path
Please note that these are potential steps derived from manual codebase analysis; our tooling does not currently have the capability to run code or execute a functional proof of concept.
- A third-party extension registering a public MIME type handler for
application/pdfis installed (available on Dev/Canary unconditionally, or behind the--enable-features=ApiMimeHandlerflag on Stable). - The user navigates to an attacker-controlled URL hosting a PDF file (e.g.,
https://attacker.com/exploit.pdf). The navigation is intercepted, and the body is buffered into aMimeHandlerBodyCacheas the extension’s handler page is loaded. - The extension page invokes
chrome.mimeHandler.abortAndFallbackToNativeHandler(). MimeHandlerStreamManager::AbortAndFallbackToNativeHandler()captures the buffered data pipe and registers it inpending_native_fallback_frames_keyed by the frame’sFrameTreeNodeId. It then initiates a reload on the same frame targetinghttps://attacker.com/exploit.pdf.- The attacker’s server responds to the reload request with an HTTP
302 Redirectpointing to a PDF resource on a victim origin (e.g.,https://victim.com/statement.pdf). - The browser-initiated navigation follows the redirect within the same frame. When the response from
victim.comis intercepted byPluginResponseInterceptorURLLoaderThrottle, it callsIsPendingNativeFallback(frame_tree_node_id_), which evaluates totruebecause the lookup is based purely on theFrameTreeNodeId. - The throttle consumes the attacker’s cached body pipe via
TakeCachedFallbackBody()and splices it into theTransferrableURLLoader, while keeping the victim’s cloned response headers. - The navigation commits, and the native PDF viewer renders the attacker’s custom PDF content under
https://victim.com/statement.pdfin the Omnibox with valid security/TLS indicators.
Root Cause Code References
-
Map Keyed Only by FTN: In
extensions/browser/mime_handler/mime_handler_stream_manager.cc:bool MimeHandlerStreamManager::IsPendingNativeFallback( content::FrameTreeNodeId frame_tree_node_id) const { return pending_native_fallback_frames_.contains(frame_tree_node_id); } -
No URL Validation in the Throttle: In
chrome/browser/plugins/plugin_response_interceptor_url_loader_throttle.cc:if (response_head->mime_type == pdf::kPDFMimeType) { auto* stream_manager = MimeHandlerStreamManager::FromWebContents(web_contents); if (stream_manager && stream_manager->IsPendingNativeFallback(frame_tree_node_id_)) { extension_id = extension_misc::kPdfExtensionId; if (chrome_pdf::features::IsOopifPdfEnabled()) { cached_body = stream_manager->TakeCachedFallbackBody(frame_tree_node_id_); } } }
Suggested Fix
To remediate this issue, modify MimeHandlerStreamManager::CachedFallbackBody to store the expected origin or URL of the fallback stream alongside the cached pipe.
When the throttle queries the fallback stream manager, require passing the current response_url. The manager should then validate that the current response_url matches the origin (or URL) of the cached fallback body before returning the pipe. If a mismatch or cross-origin redirection is detected, the cached body must be discarded, and the reload must proceed through a standard network fetch of the redirected URL.
Evaluated with Chrome root at commit: 87214e6721f6c34afd9181b80769a24c0c601c50
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.