CVE-2026-78960
Overview
Background
- MIME handler extension
- an extension that registers via
MimeTypesHandlerto handle a MIME type such asapplication/pdf; a third-partyapplication/pdfhandler needs no permissions and shows no install warning. - `PluginResponseInterceptorURLLoaderThrottle`
- the browser-process throttle that intercepts a response bound for a plugin/MIME handler and hands the handler a copy of the response head.
- CORS-safelisted response header name
- the small set of response headers (e.g.
content-type,cache-control,last-modified) thatfetch()exposes to script cross-origin.
Root Cause Analysis
77d1561238e8), which an audit found was a preparatory/helper change, not the fix. This page now points to the commit whose diff removes the bug.In PluginResponseInterceptorURLLoaderThrottle::WillProcessResponse, the throttle determines is_for_generic_mime_handler from MimeTypesHandler::IsPluginExtension() (pre-fix plugin_response_interceptor_url_loader_throttle.cc:241-243) and later deep-copies the response head, rebuilding a full copy of all headers via base::MakeRefCounted<net::HttpResponseHeaders>(response_head->headers->raw_headers()) (:294-299). That copy is placed in transferrable_loader->head (:327) and handed to the extension, which can read it back through chrome.mimeHandler.getStreamInfo(). Pre-fix nothing restricted this extension-facing header set, so a zero-permission generic handler saw every response header, including cross-origin-sensitive ones such as X-Auth-Token, that it could never obtain through fetch() without host permissions. The invariant that an extension should only see the response headers it is otherwise entitled to (the CORS-safelisted set) was violated.
The fix adds the helper FilterToCorsSafelistedResponseHeaders() and, guarded by if (is_for_generic_mime_handler && deep_copied_response->headers), strips the deep-copied headers down to names accepted by network::cors::IsCorsSafelistedResponseHeaderName() before the copy is forwarded; trusted plugin handlers and the original response_head are left unfiltered.
The patch TIGHTENS what a generic handler receives.
Attack Path
- Install a zero-permission PDF handler
Ship an extension that registers as the
application/pdfMIME handler, which requires no permissions and triggers no install warning. - User opens a cross-origin PDF The user navigates to any origin that serves a PDF, invoking the plugin response interceptor for the generic handler.
- Read leaked headers
The handler calls
chrome.mimeHandler.getStreamInfo()and readsresponseHeaders, which pre-fix included non-safelisted cross-origin headers such asX-Auth-Token.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/plugins/plugin_response_interceptor_url_loader_throttle.cc |
modified | |
ifchrome/browser/extensions/api/mime_handlers/generic_mime_handler_browsertest.cc |
modified |
Files Changed
chrome/browser/extensions/api/mime_handlers/generic_mime_handler_browsertest.ccchrome/browser/plugins/plugin_response_interceptor_url_loader_throttle.cc
Audit Directions
- Extension-facing response copiesFlag code that forwards a full
net::HttpResponseHeadersto a renderer or extension without restricting it to an explicit exposure policy. - Permissionless handler reachAudit features reachable by no-permission extensions (MIME handlers, default handlers) for cross-origin data they may expose at runtime.
Patch
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 7b53b59..fe31452 100644
--- a/chrome/browser/plugins/plugin_response_interceptor_url_loader_throttle.cc
+++ b/chrome/browser/plugins/plugin_response_interceptor_url_loader_throttle.cc
@@ -32,7 +32,9 @@
#include "mojo/public/cpp/bindings/remote.h"
#include "mojo/public/cpp/bindings/self_owned_receiver.h"
#include "mojo/public/cpp/system/data_pipe.h"
+#include "net/http/http_response_headers.h"
#include "pdf/buildflags.h"
+#include "services/network/public/cpp/cors/cors.h"
#include "services/network/public/mojom/content_security_policy.mojom.h"
#include "services/network/public/mojom/url_loader.mojom.h"
#include "services/network/public/mojom/url_response_head.mojom.h"
@@ -97,6 +99,25 @@
csp.swap(cleared);
}
+// Restricts `headers` to the CORS-safelisted response header names, so a
+// generic (third-party) MIME handler extension only sees the response headers
+// that fetch() would expose to script cross-origin. Without this, a
+// zero-permission handler could read arbitrary cross-origin response headers
+// (auth tokens, and similar) off the stream it handles.
+// https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name
+void FilterToCorsSafelistedResponseHeaders(net::HttpResponseHeaders* headers) {
+ std::vector<std::string> names_to_remove;
+ size_t iter = 0;
+ std::string name;
+ std::string value;
+ while (headers->EnumerateHeaderLines(&iter, &name, &value)) {
+ if (!network::cors::IsCorsSafelistedResponseHeaderName(name)) {
+ names_to_remove.emplace_back(name);
+ }
+ }
+ headers->RemoveHeaders(names_to_remove);
+}
+
// A no-op `network::mojom::URLLoader` used on the cached-body fallback
// path. The browser synthesizes a complete response (a buffered body
// pipe + a synchronous `OnComplete`) for the renderer; the renderer
@@ -298,6 +319,17 @@
response_head->headers->raw_headers());
}
+ // These deep-copied headers are the extension-facing view of the response
+ // (read back via getStreamInfo). Restrict a generic (third-party) handler to
+ // the CORS-safelisted response header names so it cannot read cross-origin
+ // response headers it would never see through fetch(). Trusted handlers
+ // (allowlisted plugin extensions) are exempt: their rendering paths
+ // legitimately consume non-safelisted headers, and `response_head` itself is
+ // never filtered.
+ if (is_for_generic_mime_handler && deep_copied_response->headers) {
+ FilterToCorsSafelistedResponseHeaders(deep_copied_response->headers.get());
+ }
+
// Save the original MIME type before any overrides. This is passed to
// SendExecuteMimeTypeHandlerEvent for handler URL resolution.
const std::string original_mime_type = response_head->mime_type;
diff --git a/chrome/browser/extensions/api/mime_handlers/generic_mime_handler_browsertest.cc b/chrome/browser/extensions/api/mime_handlers/generic_mime_handler_browsertest.cc
index 260fc43..2efb6e2 100644
--- a/chrome/browser/extensions/api/mime_handlers/generic_mime_handler_browsertest.cc
+++ b/chrome/browser/extensions/api/mime_handlers/generic_mime_handler_browsertest.cc
@@ -2,11 +2,15 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+#include <memory>
#include <string>
#include "base/files/file_path.h"
+#include "base/files/file_util.h"
+#include "base/functional/bind.h"
#include "base/path_service.h"
#include "base/test/scoped_feature_list.h"
+#include "base/threading/thread_restrictions.h"
#include "chrome/browser/content_settings/host_content_settings_map_factory.h"
#include "chrome/browser/extensions/extension_apitest.h"
#include "chrome/browser/plugins/plugin_utils.h"
@@ -36,6 +40,9 @@
#include "extensions/common/manifest_handlers/mime_types_handler.h"
#include "extensions/test/result_catcher.h"
#include "net/dns/mock_host_resolver.h"
+#include "net/http/http_status_code.h"
+#include "net/test/embedded_test_server/http_request.h"
+#include "net/test/embedded_test_server/http_response.h"
#include "services/network/public/cpp/permissions_policy/permissions_policy_features.h"
#include "ui/base/page_transition_types.h"
#include "url/url_constants.h"
@@ -47,6 +54,34 @@
constexpr char kPdfMimeType[] = "application/pdf";
constexpr char kTestExtensionDir[] = "generic_mime_handler";
constexpr char kTestPdfPath[] = "/test.pdf";
+constexpr char kHeaderProbePdfPath[] = "/header-probe.pdf";
+constexpr char kAuthTokenHeaderName[] = "X-Auth-Token";
+
+// Serves `pdf_body` at kHeaderProbePdfPath with a mix of response headers:
+// CORS-safelisted ones (Content-Type via set_content_type, plus Cache-Control
+// and Last-Modified) that a generic handler must still see, and a
+// non-safelisted X-Auth-Token that must be filtered out. `pdf_body` is the
+// real test.pdf, so the response body is a valid document rather than an
+// ad-hoc literal (the test inspects only headers, so its exact bytes do not
+// matter, but a valid PDF keeps the fixture honest).
+std::unique_ptr<net::test_server::HttpResponse>
+HandlePdfWithExtraResponseHeaders(
+ const std::string& pdf_body,
+ const net::test_server::HttpRequest& request) {
+ // RegisterRequestHandler invokes every registered handler in turn; returning
+ // nullptr for any other path lets the static-file handlers serve it.
+ if (request.relative_url != kHeaderProbePdfPath) {
+ return nullptr;
+ }
+ auto response = std::make_unique<net::test_server::BasicHttpResponse>();
+ response->set_code(net::HTTP_OK);
+ response->set_content_type(kPdfMimeType);
+ response->AddCustomHeader("Cache-Control", "max-age=0");
+ response->AddCustomHeader("Last-Modified", "Wed, 21 Oct 2026 07:28:00 GMT");
+ response->AddCustomHeader(kAuthTokenHeaderName, "s3cr3t");
+ response->set_content(pdf_body);
+ return response;
+}
} // namespace
@@ -71,6 +106,17 @@
test_data_dir.AppendASCII("pdf"));
embedded_test_server()->ServeFilesFromDirectory(
test_data_dir_.AppendASCII(kTestExtensionDir));
+ // Reuse the shared test PDF as the probe response body, so it is a real,
+ // valid document.
+ std::string pdf_body;
+ {
+ base::ScopedAllowBlockingForTesting allow_blocking;
+ ASSERT_TRUE(base::ReadFileToString(
+ test_data_dir_.AppendASCII(kTestExtensionDir).AppendASCII("test.pdf"),
+ &pdf_body));
+ }
+ embedded_test_server()->RegisterRequestHandler(base::BindRepeating(
+ &HandlePdfWithExtraResponseHeaders, std::move(pdf_body)));
ASSERT_TRUE(StartEmbeddedTestServer());
}
@@ -150,6 +196,48 @@
ASSERT_TRUE(catcher.GetNextResult()) << catcher.message();
}
+// A generic (third-party) MIME handler sees exactly the CORS-safelisted
+// response headers fetch() would expose cross-origin: the safelisted
+// Content-Type / Cache-Control / Last-Modified stay visible, while the custom
+// X-Auth-Token is filtered out. handler.js already asserts mimeType and the
+// %PDF- body on this same navigation, so this test covers only responseHeaders.
+IN_PROC_BROWSER_TEST_F(GenericMimeHandlerBrowserTest,
+ GetStreamInfoFiltersNonSafelistedResponseHeaders) {
+ ASSERT_TRUE(LoadExtension(test_data_dir_.AppendASCII(kTestExtensionDir)));
+
+ ResultCatcher catcher;
+ ASSERT_TRUE(
+ NavigateToURL(GetActiveWebContents(),
+ embedded_test_server()->GetURL(kHeaderProbePdfPath)));
+ ASSERT_TRUE(catcher.GetNextResult()) << catcher.message();
+
+ content::RenderFrameHost* extension_frame = FindMimeHandlerExtensionFrame();
+ ASSERT_TRUE(extension_frame);
+
+ // Whether getStreamInfo()'s responseHeaders contains `name`
+ // (case-insensitive).
+ auto has_header = [&](const char* name) {
+ return content::EvalJs(
+ extension_frame,
+ content::JsReplace(
+ "chrome.mimeHandler.getStreamInfo().then(info =>"
+ " Object.keys(info.responseHeaders)"
+ " .some(k => k.toLowerCase() === $1.toLowerCase()))",
+ name));
+ };
+
+ // Every CORS-safelisted response header the server sent must stay visible.
+ // These positive checks are also the vacuity guard: were responseHeaders
+ // ever empty or getStreamInfo() to fail, they would fail here instead of
+ // letting the X-Auth-Token check below pass against an empty object.
+ EXPECT_EQ(true, has_header("Content-Type"));
+ EXPECT_EQ(true, has_header("Cache-Control"));
+ EXPECT_EQ(true, has_header("Last-Modified"));
+
+ // The non-safelisted header must be stripped.
+ EXPECT_EQ(false, has_header("X-Auth-Token"));
+}
+
// Verifies that a generic MIME handler with `can_embed: false` is selected
// for top-level navigations but bypassed for embedded loads.
IN_PROC_BROWSER_TEST_F(GenericMimeHandlerBrowserTest,
Regression Test / PoC
diff --git a/chrome/browser/extensions/api/mime_handlers/generic_mime_handler_browsertest.cc b/chrome/browser/extensions/api/mime_handlers/generic_mime_handler_browsertest.cc
index 260fc43..2efb6e2 100644
--- a/chrome/browser/extensions/api/mime_handlers/generic_mime_handler_browsertest.cc
+++ b/chrome/browser/extensions/api/mime_handlers/generic_mime_handler_browsertest.cc
@@ -2,11 +2,15 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+#include <memory>
#include <string>
#include "base/files/file_path.h"
+#include "base/files/file_util.h"
+#include "base/functional/bind.h"
#include "base/path_service.h"
#include "base/test/scoped_feature_list.h"
+#include "base/threading/thread_restrictions.h"
#include "chrome/browser/content_settings/host_content_settings_map_factory.h"
#include "chrome/browser/extensions/extension_apitest.h"
#include "chrome/browser/plugins/plugin_utils.h"
@@ -36,6 +40,9 @@
#include "extensions/common/manifest_handlers/mime_types_handler.h"
#include "extensions/test/result_catcher.h"
#include "net/dns/mock_host_resolver.h"
+#include "net/http/http_status_code.h"
+#include "net/test/embedded_test_server/http_request.h"
+#include "net/test/embedded_test_server/http_response.h"
#include "services/network/public/cpp/permissions_policy/permissions_policy_features.h"
#include "ui/base/page_transition_types.h"
#include "url/url_constants.h"
@@ -47,6 +54,34 @@
constexpr char kPdfMimeType[] = "application/pdf";
constexpr char kTestExtensionDir[] = "generic_mime_handler";
constexpr char kTestPdfPath[] = "/test.pdf";
+constexpr char kHeaderProbePdfPath[] = "/header-probe.pdf";
+constexpr char kAuthTokenHeaderName[] = "X-Auth-Token";
+
+// Serves `pdf_body` at kHeaderProbePdfPath with a mix of response headers:
+// CORS-safelisted ones (Content-Type via set_content_type, plus Cache-Control
+// and Last-Modified) that a generic handler must still see, and a
+// non-safelisted X-Auth-Token that must be filtered out. `pdf_body` is the
+// real test.pdf, so the response body is a valid document rather than an
+// ad-hoc literal (the test inspects only headers, so its exact bytes do not
+// matter, but a valid PDF keeps the fixture honest).
+std::unique_ptr<net::test_server::HttpResponse>
+HandlePdfWithExtraResponseHeaders(
+ const std::string& pdf_body,
+ const net::test_server::HttpRequest& request) {
+ // RegisterRequestHandler invokes every registered handler in turn; returning
+ // nullptr for any other path lets the static-file handlers serve it.
+ if (request.relative_url != kHeaderProbePdfPath) {
+ return nullptr;
+ }
+ auto response = std::make_unique<net::test_server::BasicHttpResponse>();
+ response->set_code(net::HTTP_OK);
+ response->set_content_type(kPdfMimeType);
+ response->AddCustomHeader("Cache-Control", "max-age=0");
+ response->AddCustomHeader("Last-Modified", "Wed, 21 Oct 2026 07:28:00 GMT");
+ response->AddCustomHeader(kAuthTokenHeaderName, "s3cr3t");
+ response->set_content(pdf_body);
+ return response;
+}
} // namespace
@@ -71,6 +106,17 @@
test_data_dir.AppendASCII("pdf"));
embedded_test_server()->ServeFilesFromDirectory(
test_data_dir_.AppendASCII(kTestExtensionDir));
+ // Reuse the shared test PDF as the probe response body, so it is a real,
+ // valid document.
+ std::string pdf_body;
+ {
+ base::ScopedAllowBlockingForTesting allow_blocking;
+ ASSERT_TRUE(base::ReadFileToString(
+ test_data_dir_.AppendASCII(kTestExtensionDir).AppendASCII("test.pdf"),
+ &pdf_body));
+ }
+ embedded_test_server()->RegisterRequestHandler(base::BindRepeating(
+ &HandlePdfWithExtraResponseHeaders, std::move(pdf_body)));
ASSERT_TRUE(StartEmbeddedTestServer());
}
@@ -150,6 +196,48 @@
ASSERT_TRUE(catcher.GetNextResult()) << catcher.message();
}
+// A generic (third-party) MIME handler sees exactly the CORS-safelisted
+// response headers fetch() would expose cross-origin: the safelisted
+// Content-Type / Cache-Control / Last-Modified stay visible, while the custom
+// X-Auth-Token is filtered out. handler.js already asserts mimeType and the
+// %PDF- body on this same navigation, so this test covers only responseHeaders.
+IN_PROC_BROWSER_TEST_F(GenericMimeHandlerBrowserTest,
+ GetStreamInfoFiltersNonSafelistedResponseHeaders) {
+ ASSERT_TRUE(LoadExtension(test_data_dir_.AppendASCII(kTestExtensionDir)));
+
+ ResultCatcher catcher;
+ ASSERT_TRUE(
+ NavigateToURL(GetActiveWebContents(),
+ embedded_test_server()->GetURL(kHeaderProbePdfPath)));
+ ASSERT_TRUE(catcher.GetNextResult()) << catcher.message();
+
+ content::RenderFrameHost* extension_frame = FindMimeHandlerExtensionFrame();
+ ASSERT_TRUE(extension_frame);
+
+ // Whether getStreamInfo()'s responseHeaders contains `name`
+ // (case-insensitive).
+ auto has_header = [&](const char* name) {
+ return content::EvalJs(
+ extension_frame,
+ content::JsReplace(
+ "chrome.mimeHandler.getStreamInfo().then(info =>"
+ " Object.keys(info.responseHeaders)"
+ " .some(k => k.toLowerCase() === $1.toLowerCase()))",
+ name));
+ };
+
+ // Every CORS-safelisted response header the server sent must stay visible.
+ // These positive checks are also the vacuity guard: were responseHeaders
+ // ever empty or getStreamInfo() to fail, they would fail here instead of
+ // letting the X-Auth-Token check below pass against an empty object.
+ EXPECT_EQ(true, has_header("Content-Type"));
+ EXPECT_EQ(true, has_header("Cache-Control"));
+ EXPECT_EQ(true, has_header("Last-Modified"));
+
+ // The non-safelisted header must be stripped.
+ EXPECT_EQ(false, has_header("X-Auth-Token"));
+}
+
// Verifies that a generic MIME handler with `can_embed: false` is selected
// for top-level navigations but bypassed for embedded loads.
IN_PROC_BROWSER_TEST_F(GenericMimeHandlerBrowserTest,
Original Bug Report
Cross-Origin User Information Disclosure by mime_types_handler
Security Bug
Important: Please do not change the component of this bug manually.
Please READ THIS FAQ before filing a bug: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/faq.md
Please see the following link for instructions on filing security bugs: https://www.chromium.org/Home/chromium-security/reporting-security-bugs
Reports may be eligible for reward payments under the Chrome VRP: https://g.co/chrome/vrp
NOTE: Security bugs are normally made public once a fix has been widely deployed.
VULNERABILITY DETAILS An extension that requests no permissions can set itself as the default handler for PDF documents. Since it requests no permissions - there will be no warning dialogue when the user installs the extension. When the user downloads a PDF inside any Chromium-based browser with the extension installed - the extension can access all of the HTTP Response Headers (except for Cookie based headers: set-cookie, set-cookie2, clear-site-data). I believe this is an unintended cross-origin user information disclosure, as the extension can access any other sensitive response header from any origin where the user gets a PDF with no warning at install-time nor at runtime.
VERSION Chrome Version: 152.0.7947.0 dev Operating System: MacOS 26.5 (25F71)
REPRODUCTION CASE
- Move manifest.json, viewer.html and viewer.js into a folder named extension and install this extension to a Chromium browser by installing an unpacked extension.
- Run server.py using Python 3
- Navigate in the browser to http://127.0.0.1:38180/victim.pdf
- You should now see an output that looks like this: { “sentinel”: “PDF-ID”, “originalUrl”: “http://127.0.0.1:38180/victim.pdf”, “mimeType”: “application/pdf”, “tabId”: 306537361, “embedded”: false, “bodyBytes”: 125, “bodyMagic”: “%PDF-”, “responseHeaders”: { “Content-Length”: “125”, “Content-Type”: “application/pdf”, “Date”: “Sun, 12 Jul 2026 15:01:14 GMT”, “Server”: “BaseHTTP/0.6 Python/3.14.6”, “X-Auth-Token”: “secret-would-be-here” } }
As you can see, X-Auth-Token appears and the extension was able to get it.
CREDIT INFORMATION Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited? Reporter credit: Oran Simhony from Palo Alto Networks