Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Network
DescriptionInappropriate implementation in Network
ComponentNetwork
Bug ClassLogic Error
Tracker514481943
Fix commit7e61121dea60 (chromium/src) +122/-71
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
net/base/mime_sniffer.cc
modified
GURL
net/base/mime_sniffer.h
modified
HttpResponseHeaders
net/base/mime_sniffer.h
modified
TEST
net/base/mime_sniffer_unittest.cc
modified
for
net/base/mime_sniffer_unittest.cc
modified

Files Changed

  • net/base/mime_sniffer.cc
  • net/base/mime_sniffer.h
  • net/base/mime_sniffer_unittest.cc
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},
Loading diff…

Regression Test / PoC

shipped with the fix
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(), ""));
   }
 }
Loading diff…

Original Bug Report

reported by rj...@google.com

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.cc
  • third_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.

  1. An attacker identifies a target application that allows file uploads but serves them with an empty Content-Type.
  2. 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 second nosniff header in the HTTP response.
  3. The attacker uploads a malicious file containing HTML and JavaScript: <html><script>alert(origin)</script></html>.
  4. A victim is tricked into navigating to the uploaded file’s URL.
  5. Chrome receives the response containing multiple X-Content-Type-Options: nosniff headers.
  6. GetNormalizedHeader coalesces them into the single string "nosniff, nosniff".
  7. The strict EqualsCaseInsensitiveASCII check in ShouldSniffContent fails, returning false for sniffing_blocked.
  8. Chrome’s URLLoader sees that sniffing is allowed. It inspects the initial bytes of the payload, matches the <html> tag, and updates the response MIME type to text/html.
  9. The Blink renderer processes the response as an HTML document, executing the malicious JavaScript in the context of the target application.

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.

View on issue tracker