Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Extensions
DescriptionInsufficient validation of untrusted input in Extensions
ComponentExtensions
Bug ClassLogic Error
Tracker519244446
Fix commit88ac5b42a6b2 (chromium/src) +273/-73
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-21

Changed Functions

FunctionChangeNotes
MimeHandlerFallbackRedirectBrowserTest
chrome/browser/extensions/api/mime_handlers/mime_handler_fallback_browsertest.cc
modified
if
chrome/browser/plugins/plugin_response_interceptor_url_loader_throttle.cc
modified

Files Changed

  • chrome/browser/extensions/api/mime_handlers/mime_handler_fallback_browsertest.cc
  • chrome/browser/plugins/plugin_response_interceptor_url_loader_throttle.cc
From 88ac5b42a6b20f104ecf913dfb970a2b0abd2b4a Mon Sep 17 00:00:00 2001
From: Andy Phan <[email protected]>
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 <[email protected]>
Reviewed-by: Lei Zhang <[email protected]>
Commit-Queue: Andy Phan <[email protected]>
Reviewed-by: Maksim Sisov <[email protected]>
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,
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • chrome/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.

  1. A third-party extension registering a public MIME type handler for application/pdf is installed (available on Dev/Canary unconditionally, or behind the --enable-features=ApiMimeHandler flag on Stable).
  2. 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 a MimeHandlerBodyCache as the extension’s handler page is loaded.
  3. The extension page invokes chrome.mimeHandler.abortAndFallbackToNativeHandler().
  4. MimeHandlerStreamManager::AbortAndFallbackToNativeHandler() captures the buffered data pipe and registers it in pending_native_fallback_frames_ keyed by the frame’s FrameTreeNodeId. It then initiates a reload on the same frame targeting https://attacker.com/exploit.pdf.
  5. The attacker’s server responds to the reload request with an HTTP 302 Redirect pointing to a PDF resource on a victim origin (e.g., https://victim.com/statement.pdf).
  6. The browser-initiated navigation follows the redirect within the same frame. When the response from victim.com is intercepted by PluginResponseInterceptorURLLoaderThrottle, it calls IsPendingNativeFallback(frame_tree_node_id_), which evaluates to true because the lookup is based purely on the FrameTreeNodeId.
  7. The throttle consumes the attacker’s cached body pipe via TakeCachedFallbackBody() and splices it into the TransferrableURLLoader, while keeping the victim’s cloned response headers.
  8. The navigation commits, and the native PDF viewer renders the attacker’s custom PDF content under https://victim.com/statement.pdf in 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.

View on issue tracker