CVE-2026-14001
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifnet/base/mime_sniffer.cc |
modified | |
GURLnet/base/mime_sniffer.h |
modified | |
HttpResponseHeadersnet/base/mime_sniffer.h |
modified | |
TESTnet/base/mime_sniffer_unittest.cc |
modified | |
fornet/base/mime_sniffer_unittest.cc |
modified |
Files Changed
net/base/mime_sniffer.ccnet/base/mime_sniffer.hnet/base/mime_sniffer_unittest.cc
Patch
From 7e61121dea6066ad613b2abc9558eb7a876efa71 Mon Sep 17 00:00:00 2001
From: Matt Menke <mmenke@chromium.org>
Date: Mon, 25 May 2026 09:25:35 -0700
Subject: [PATCH] Fix handling of multiple X-Content-Type-Options headers.
Previously, we compared the unified/normalized header against "nosniff",
so if there were two nosniff headers, we'd compare "nosniff, nosniff"
against "nosniff", and get false, so would not respect the requested
behavior.
This CL makes us correctly look at only the first nosniff header,
which is consistent with the spec.
It also moves the logic to do this into net::ShouldSniffMimeType().
Previously, we had two locations independently looking for the
header and also calling into net::ShouldSniffMimeType(), which left
more room for bugs.
Bug: 514481943
Change-Id: Ied73ddbfe2f4203d1c485deedb414c524a769a28
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7863616
Reviewed-by: Adam Rice <ricea@chromium.org>
Commit-Queue: mmenke <mmenke@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1635749}
---
diff --git a/net/base/mime_sniffer.cc b/net/base/mime_sniffer.cc
index e746623..0f9198d 100644
--- a/net/base/mime_sniffer.cc
+++ b/net/base/mime_sniffer.cc
@@ -83,16 +83,19 @@
// Note that our definition of HTML payload is much stricter than IE's
// definition and roughly the same as Firefox's definition.
-#include <stdint.h>
-#include <string>
-
#include "net/base/mime_sniffer.h"
+#include <stdint.h>
+
+#include <string>
+#include <string_view>
+
#include "base/check_op.h"
#include "base/containers/span.h"
#include "base/notreached.h"
#include "base/strings/string_util.h"
#include "build/build_config.h"
+#include "net/http/http_response_headers.h"
#include "url/gurl.h"
namespace net {
@@ -653,14 +656,28 @@
return CheckForMagicNumbers(content, kCRXMagicNumbers, result);
}
-bool ShouldSniffMimeType(const GURL& url, std::string_view mime_type) {
+bool ShouldSniffMimeType(const GURL& url,
+ const HttpResponseHeaders* http_response_headers,
+ std::string_view mime_type) {
bool sniffable_scheme = url.is_empty() || url.SchemeIsHTTPOrHTTPS() ||
#if BUILDFLAG(IS_ANDROID)
url.SchemeIs("content") ||
#endif
url.SchemeIsFile() || url.SchemeIsFileSystem();
- if (!sniffable_scheme)
+ if (!sniffable_scheme) {
return false;
+ }
+
+ // If the "x-content-type-options" header is "nosniff", do not sniff. Only the
+ // first matching header is checked, per the fetch spec.
+ if (http_response_headers) {
+ std::optional<std::string_view> header =
+ http_response_headers->EnumerateHeader(/*iter=*/nullptr,
+ "x-content-type-options");
+ if (header && base::EqualsCaseInsensitiveASCII(*header, "nosniff")) {
+ return false;
+ }
+ }
static const char* const kSniffableTypes[] = {
// Many web servers are misconfigured to send text/plain for many
diff --git a/net/base/mime_sniffer.h b/net/base/mime_sniffer.h
index 9cba2ab..90d855fc 100644
--- a/net/base/mime_sniffer.h
+++ b/net/base/mime_sniffer.h
@@ -14,6 +14,10 @@
class GURL;
+namespace net {
+class HttpResponseHeaders;
+}
+
// -----------------------------------------------------------------------------
// When the MIME type of a resource is sniffed, it will potentially be used in
// a manner other than that the server-provided Content-Type indicated it should
@@ -45,11 +49,16 @@
// Examine the URL and the mime_type and decide whether to sniff a replacement
// mime type from the content.
//
-// |url| is the URL from which the content was obtained.
-// |mime_type| is the current mime type, e.g. from the Content-Type header.
+// `http_response_headers` are the headers associated with the response. They're
+// checked for the "nosniff" header. It may be null. Per spec, only the first
+// X-Content-Type-Options header is checked.
+// `url` is the URL from which the content was obtained.
+// `mime_type` is the current mime type, e.g. from the Content-Type header.
// Returns true if the mime type should be sniffed.
-NET_EXPORT bool ShouldSniffMimeType(const GURL& url,
- std::string_view mime_type);
+NET_EXPORT bool ShouldSniffMimeType(
+ const GURL& url,
+ const HttpResponseHeaders* http_response_headers,
+ std::string_view mime_type);
// Guess a mime type from the first few bytes of content an its URL. Always
// assigns |result| with its best guess of a mime type.
diff --git a/net/base/mime_sniffer_unittest.cc b/net/base/mime_sniffer_unittest.cc
index ff694d4..008fbe7 100644
--- a/net/base/mime_sniffer_unittest.cc
+++ b/net/base/mime_sniffer_unittest.cc
@@ -4,7 +4,12 @@
#include "net/base/mime_sniffer.h"
+#include <string>
+#include <string_view>
+
+#include "base/memory/ref_counted.h"
#include "build/build_config.h"
+#include "net/http/http_response_headers.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"
#include "url/url_constants.h"
@@ -34,33 +39,73 @@
}
TEST(MimeSnifferTest, SniffableSchemes) {
- struct {
+ const struct {
const char* scheme;
bool sniffable;
} kTestCases[] = {
- {url::kAboutScheme, false},
- {url::kBlobScheme, false},
+ {url::kAboutScheme, false}, {url::kBlobScheme, false},
#if BUILDFLAG(IS_ANDROID)
- {url::kContentScheme, true},
+ {url::kContentScheme, true},
#else
- {url::kContentScheme, false},
+ {url::kContentScheme, false},
#endif
- {url::kContentIDScheme, false},
- {url::kDataScheme, false},
- {url::kFileScheme, true},
- {url::kFileSystemScheme, true},
- {url::kFtpScheme, false},
- {url::kHttpScheme, true},
- {url::kHttpsScheme, true},
- {url::kJavaScriptScheme, false},
- {url::kMailToScheme, false},
- {url::kWsScheme, false},
- {url::kWssScheme, false}
+ {url::kContentIDScheme, false}, {url::kDataScheme, false},
+ {url::kFileScheme, true}, {url::kFileSystemScheme, true},
+ {url::kFtpScheme, false}, {url::kHttpScheme, true},
+ {url::kHttpsScheme, true}, {url::kJavaScriptScheme, false},
+ {url::kMailToScheme, false}, {url::kWsScheme, false},
+ {url::kWssScheme, false}};
+
+ for (const auto& test_case : kTestCases) {
+ GURL url(std::string(test_case.scheme) + "://host/path/whatever");
+ EXPECT_EQ(test_case.sniffable,
+ ShouldSniffMimeType(url, /*http_response_headers=*/nullptr, ""));
+ }
+}
+
+TEST(MimeSnifferTest, SniffableHeaders) {
+ const GURL kSniffableUrl("https://test/");
+ constexpr std::string_view kXContentTypeOptions = "x-content-type-options";
+ const struct {
+ scoped_refptr<HttpResponseHeaders> headers;
+ bool sniffable;
+ } kTestCases[] = {
+ {nullptr, true},
+ {HttpResponseHeaders::Builder(/*version=*/{1, 1}, /*status=*/"200 OK")
+ .Build(),
+ true},
+ {HttpResponseHeaders::Builder(/*version=*/{1, 1}, /*status=*/"200 OK")
+ .AddHeader(kXContentTypeOptions, "foo")
+ .Build(),
+ true},
+ {HttpResponseHeaders::Builder(/*version=*/{1, 1}, /*status=*/"200 OK")
+ .AddHeader(kXContentTypeOptions, "nosniff")
+ .Build(),
+ false},
Regression Test / PoC
diff --git a/net/base/mime_sniffer_unittest.cc b/net/base/mime_sniffer_unittest.cc
index ff694d4..008fbe7 100644
--- a/net/base/mime_sniffer_unittest.cc
+++ b/net/base/mime_sniffer_unittest.cc
@@ -4,7 +4,12 @@
#include "net/base/mime_sniffer.h"
+#include <string>
+#include <string_view>
+
+#include "base/memory/ref_counted.h"
#include "build/build_config.h"
+#include "net/http/http_response_headers.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"
#include "url/url_constants.h"
@@ -34,33 +39,73 @@
}
TEST(MimeSnifferTest, SniffableSchemes) {
- struct {
+ const struct {
const char* scheme;
bool sniffable;
} kTestCases[] = {
- {url::kAboutScheme, false},
- {url::kBlobScheme, false},
+ {url::kAboutScheme, false}, {url::kBlobScheme, false},
#if BUILDFLAG(IS_ANDROID)
- {url::kContentScheme, true},
+ {url::kContentScheme, true},
#else
- {url::kContentScheme, false},
+ {url::kContentScheme, false},
#endif
- {url::kContentIDScheme, false},
- {url::kDataScheme, false},
- {url::kFileScheme, true},
- {url::kFileSystemScheme, true},
- {url::kFtpScheme, false},
- {url::kHttpScheme, true},
- {url::kHttpsScheme, true},
- {url::kJavaScriptScheme, false},
- {url::kMailToScheme, false},
- {url::kWsScheme, false},
- {url::kWssScheme, false}
+ {url::kContentIDScheme, false}, {url::kDataScheme, false},
+ {url::kFileScheme, true}, {url::kFileSystemScheme, true},
+ {url::kFtpScheme, false}, {url::kHttpScheme, true},
+ {url::kHttpsScheme, true}, {url::kJavaScriptScheme, false},
+ {url::kMailToScheme, false}, {url::kWsScheme, false},
+ {url::kWssScheme, false}};
+
+ for (const auto& test_case : kTestCases) {
+ GURL url(std::string(test_case.scheme) + "://host/path/whatever");
+ EXPECT_EQ(test_case.sniffable,
+ ShouldSniffMimeType(url, /*http_response_headers=*/nullptr, ""));
+ }
+}
+
+TEST(MimeSnifferTest, SniffableHeaders) {
+ const GURL kSniffableUrl("https://test/");
+ constexpr std::string_view kXContentTypeOptions = "x-content-type-options";
+ const struct {
+ scoped_refptr<HttpResponseHeaders> headers;
+ bool sniffable;
+ } kTestCases[] = {
+ {nullptr, true},
+ {HttpResponseHeaders::Builder(/*version=*/{1, 1}, /*status=*/"200 OK")
+ .Build(),
+ true},
+ {HttpResponseHeaders::Builder(/*version=*/{1, 1}, /*status=*/"200 OK")
+ .AddHeader(kXContentTypeOptions, "foo")
+ .Build(),
+ true},
+ {HttpResponseHeaders::Builder(/*version=*/{1, 1}, /*status=*/"200 OK")
+ .AddHeader(kXContentTypeOptions, "nosniff")
+ .Build(),
+ false},
+ {HttpResponseHeaders::Builder(/*version=*/{1, 1}, /*status=*/"200 OK")
+ .AddHeader(kXContentTypeOptions, "NosNiFF")
+ .Build(),
+ false},
+ {HttpResponseHeaders::Builder(/*version=*/{1, 1}, /*status=*/"200 OK")
+ .AddHeader(kXContentTypeOptions, "nosniff")
+ .AddHeader(kXContentTypeOptions, "nosniff")
+ .Build(),
+ false},
+ {HttpResponseHeaders::Builder(/*version=*/{1, 1}, /*status=*/"200 OK")
+ .AddHeader(kXContentTypeOptions, "nosniff")
+ .AddHeader(kXContentTypeOptions, "sniff")
+ .Build(),
+ false},
+ {HttpResponseHeaders::Builder(/*version=*/{1, 1}, /*status=*/"200 OK")
+ .AddHeader(kXContentTypeOptions, "sniff")
+ .AddHeader(kXContentTypeOptions, "nosniff")
+ .Build(),
+ true},
};
- for (const auto test_case : kTestCases) {
- GURL url(std::string(test_case.scheme) + "://host/path/whatever");
- EXPECT_EQ(test_case.sniffable, ShouldSniffMimeType(url, ""));
+ for (const auto& test_case : kTestCases) {
+ EXPECT_EQ(test_case.sniffable,
+ ShouldSniffMimeType(kSniffableUrl, test_case.headers.get(), ""));
}
}
Original Bug Report
Bypass of X-Content-Type-Options: nosniff via duplicated headers
Flapjack, 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: Chrome’s MIME sniffing logic fails to recognize the X-Content-Type-Options: nosniff directive if multiple such headers are present in the HTTP response. The code incorrectly uses a strict string equality check against the concatenated header values (e.g., “nosniff, nosniff”). This bypass can lead to Cross-Site Scripting (XSS) if an attacker can upload an HTML file to an endpoint that outputs duplicate nosniff headers.
Affected files:
services/network/public/cpp/header_util.ccthird_party/blink/common/loader/mime_sniffing_throttle.cc
Estimated timestamp from git blame: Unknown (Google3 checkout)
Description
There is a potential bypass of the X-Content-Type-Options: nosniff security mitigation when a server responds with multiple instances of the header.
In services/network/public/cpp/header_util.cc (ShouldSniffContent) and third_party/blink/common/loader/mime_sniffing_throttle.cc (MimeSniffingThrottle::WillProcessResponse), Chrome determines if MIME sniffing should be disabled. To do this, the code retrieves the header using net::HttpResponseHeaders::GetNormalizedHeader("x-content-type-options").
When multiple headers of the same name exist in an HTTP response, GetNormalizedHeader coalesces their values into a single comma-separated string (e.g., nosniff, nosniff). The code then performs a strict case-insensitive equality check on the entire concatenated string:
// From services/network/public/cpp/header_util.cc
bool sniffing_blocked =
base::EqualsCaseInsensitiveASCII(content_type_options, "nosniff");
Because "nosniff, nosniff" does not strictly equal "nosniff", sniffing_blocked evaluates to false. As a result, Chrome proceeds with MIME sniffing even when the server explicitly intended to block it.
Potential Impact
If an attacker can cause a response to include multiple nosniff headers (e.g., via HTTP header injection, or a server/CDN misconfiguration where multiple layers append the header), they can bypass the MIME sniffing protection. If the server serves user-uploaded content with a missing or unknown Content-Type, Chrome will fall back to sniffing the content. An attacker-uploaded payload containing HTML would be sniffed as text/html instead of safely defaulting to text/plain, potentially leading to Cross-Site Scripting (XSS).
Suggested Attacker Steps
Note: These are potential steps to trigger the vulnerability, as our tooling agent cannot execute live code to provide a working proof of concept.
- An attacker identifies a target application that allows file uploads but serves them with an empty
Content-Type. - The server attempts to mitigate XSS by applying
X-Content-Type-Options: nosniff, but a misconfiguration (or header injection vulnerability) allows the attacker to force a secondnosniffheader in the HTTP response. - The attacker uploads a malicious file containing HTML and JavaScript:
<html><script>alert(origin)</script></html>. - A victim is tricked into navigating to the uploaded file’s URL.
- Chrome receives the response containing multiple
X-Content-Type-Options: nosniffheaders. GetNormalizedHeadercoalesces them into the single string"nosniff, nosniff".- The strict
EqualsCaseInsensitiveASCIIcheck inShouldSniffContentfails, returningfalseforsniffing_blocked. - Chrome’s
URLLoadersees that sniffing is allowed. It inspects the initial bytes of the payload, matches the<html>tag, and updates the response MIME type totext/html. - The Blink renderer processes the response as an HTML document, executing the malicious JavaScript in the context of the target application.
Recommended Fix
According to the Fetch specification, the browser should split the X-Content-Type-Options header values by commas and check if the first value is a case-insensitive match for "nosniff".
The checks in services/network/public/cpp/header_util.cc and third_party/blink/common/loader/mime_sniffing_throttle.cc should be updated to use net::HttpUtil::ValuesIterator to safely parse the header string. This would match the more robust specification-compliant logic currently implemented for Opaque Response Blocking (ORB) in services/network/orb/orb_impl.cc (HasNoSniff).
Evaluated with Chrome root at commit: b7d0c4d810da1b31400f198c70d9720fc8f0e5a0
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.