Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in ORB
DescriptionInappropriate implementation in ORB
ComponentORB
Bug ClassLogic Error
Tracker502615170
Fix commitf8797d82bad0 (chromium/src) +71/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
services/network/orb/orb_impl.cc
modified
TEST
services/network/orb/orb_impl_unittest.cc
modified
for
services/network/orb/orb_impl_unittest.cc
modified
if
services/network/orb/orb_impl_unittest.cc
modified

Files Changed

  • services/network/orb/orb_impl.cc
  • services/network/orb/orb_impl_unittest.cc
From f8797d82bad021284b69d4038efcf30dc44c02be Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <[email protected]>
Date: Wed, 15 Apr 2026 11:04:39 -0700
Subject: [PATCH] ORB: Make HasNoSniff spec-compliant

This CL updates the HasNoSniff function in ORB to follow the Fetch
specification for determining 'nosniff'. Specifically, it now correctly
handles duplicate X-Content-Type-Options headers by checking only the
first token and ensures that empty leading tokens are not ignored.

A new test suite, NosniffSpecCompliance, is added to
orb_impl_unittest.cc with test cases derived from the Web Platform Tests
(WPT) to ensure ongoing compliance.

crrev.com/c/5002059 did this for Blink but not the network service.

Fixed: 502615170
Change-Id: I6b171eada0a68285cad493e9973ddb6dba89cdf1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7764126
Reviewed-by: Łukasz Anforowicz <[email protected]>
Commit-Queue: Andrew Paseltiner <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1615291}
---

diff --git a/services/network/orb/orb_impl.cc b/services/network/orb/orb_impl.cc
index 71ec880..b6a6126 100644
--- a/services/network/orb/orb_impl.cc
+++ b/services/network/orb/orb_impl.cc
@@ -206,15 +206,17 @@
 
 bool HasNoSniff(
     const mojom::URLResponseHead& response) {
-  // TODO(vogelheim): Check for compatibility with spec &
-  //   ParseContentTypeOptionsHeader. Maybe move this to parsed_headers.
+  // https://fetch.spec.whatwg.org/#determine-nosniff
   if (!response.headers) {
     return false;
   }
   std::string nosniff_header =
       response.headers->GetNormalizedHeader("x-content-type-options")
           .value_or(std::string());
-  return base::EqualsCaseInsensitiveASCII(nosniff_header, "nosniff");
+  net::HttpUtil::ValuesIterator it(nosniff_header, ',',
+                                   /*ignore_empty_values=*/false);
+  return it.GetNext() &&
+         base::EqualsCaseInsensitiveASCII(it.value(), "nosniff");
 }
 
 }  // namespace
diff --git a/services/network/orb/orb_impl_unittest.cc b/services/network/orb/orb_impl_unittest.cc
index fc94db6e..77cde4a 100644
--- a/services/network/orb/orb_impl_unittest.cc
+++ b/services/network/orb/orb_impl_unittest.cc
@@ -1922,4 +1922,70 @@
   }
 }
 
+TEST(CrossOriginReadBlockingTest, NosniffSpecCompliance) {
+  struct {
+    const char* description;
+    const char* header;
+    bool expected_nosniff;
+  } kTestCases[] = {
+      {"Upper case", "X-Content-Type-Options: NOSNIFF\n", true},
+      {"Mixed case", "x-content-type-OPTIONS: nosniff\n", true},
+      {"Nosniff with junk after comma",
+       "X-Content-Type-Options: nosniff,,@#$#%%&^&^*()()11!\n", true},
+      {"Junk before nosniff",
+       "X-Content-Type-Options: @#$#%%&^&^*()()11!,nosniff\n", false},
+      {"Multiple headers, nosniff first",
+       "X-Content-Type-Options: nosniff\n"
+       "X-Content-Type-Options: no\n",
+       true},
+      {"Multiple headers, nosniff second",
+       "X-Content-Type-Options: no\n"
+       "X-Content-Type-Options: nosniff\n",
+       false},
+      {"Empty first header",
+       "X-Content-Type-Options: \n"
+       "X-Content-Type-Options: nosniff\n",
+       false},
+      {"Duplicate nosniff",
+       "X-Content-Type-Options: nosniff\n"
+       "X-Content-Type-Options: nosniff\n",
+       true},
+      {"Leading comma", "X-Content-Type-Options: ,nosniff\n", false},
+      {"Trailing form feed", "X-Content-Type-Options: nosniff\f\n", false},
+      {"Trailing vertical tab", "X-Content-Type-Options: nosniff\v\n", false},
+      {"Trailing vertical tab before comma",
+       "X-Content-Type-Options: nosniff\v,nosniff\n", false},
+      {"Single quoted", "X-Content-Type-Options: 'NosniFF'\n", false},
+      {"Double quoted", "X-Content-Type-Options: \"nosniFF\"\n", false},
+      {"Missing X-", "Content-Type-Options: nosniff\n", false},
+  };
+
+  for (const auto& test_case : kTestCases) {
+    SCOPED_TRACE(test_case.description);
+    PerFactoryState per_factory_state;
+    auto analyzer =
+        std::make_unique<OpaqueResponseBlockingAnalyzer>(&per_factory_state);
+
+    auto response = CreateResponse(
+        "HTTP/1.1 200 OK\n"
+        "Content-Type: application/json\n" +
+        std::string(test_case.header));
+    // Use application/json to ensure that nosniff detection leads to an
+    // immediate block.
+    response->mime_type = "application/json";
+
+    ResponseAnalyzer::Decision decision =
+        analyzer->Init(GURL("https://target.test"),
+                       url::Origin::Create(GURL("https://initiator.test")),
+                       mojom::RequestMode::kNoCors,
+                       mojom::RequestDestination::kEmpty, *response);
+
+    if (test_case.expected_nosniff) {
+      EXPECT_EQ(ResponseAnalyzer::Decision::kBlock, decision);
+    } else {
+      EXPECT_EQ(ResponseAnalyzer::Decision::kSniffMore, decision);
+    }
+  }
+}
+
 }  // namespace network::orb
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/services/network/orb/orb_impl_unittest.cc b/services/network/orb/orb_impl_unittest.cc
index fc94db6e..77cde4a 100644
--- a/services/network/orb/orb_impl_unittest.cc
+++ b/services/network/orb/orb_impl_unittest.cc
@@ -1922,4 +1922,70 @@
   }
 }
 
+TEST(CrossOriginReadBlockingTest, NosniffSpecCompliance) {
+  struct {
+    const char* description;
+    const char* header;
+    bool expected_nosniff;
+  } kTestCases[] = {
+      {"Upper case", "X-Content-Type-Options: NOSNIFF\n", true},
+      {"Mixed case", "x-content-type-OPTIONS: nosniff\n", true},
+      {"Nosniff with junk after comma",
+       "X-Content-Type-Options: nosniff,,@#$#%%&^&^*()()11!\n", true},
+      {"Junk before nosniff",
+       "X-Content-Type-Options: @#$#%%&^&^*()()11!,nosniff\n", false},
+      {"Multiple headers, nosniff first",
+       "X-Content-Type-Options: nosniff\n"
+       "X-Content-Type-Options: no\n",
+       true},
+      {"Multiple headers, nosniff second",
+       "X-Content-Type-Options: no\n"
+       "X-Content-Type-Options: nosniff\n",
+       false},
+      {"Empty first header",
+       "X-Content-Type-Options: \n"
+       "X-Content-Type-Options: nosniff\n",
+       false},
+      {"Duplicate nosniff",
+       "X-Content-Type-Options: nosniff\n"
+       "X-Content-Type-Options: nosniff\n",
+       true},
+      {"Leading comma", "X-Content-Type-Options: ,nosniff\n", false},
+      {"Trailing form feed", "X-Content-Type-Options: nosniff\f\n", false},
+      {"Trailing vertical tab", "X-Content-Type-Options: nosniff\v\n", false},
+      {"Trailing vertical tab before comma",
+       "X-Content-Type-Options: nosniff\v,nosniff\n", false},
+      {"Single quoted", "X-Content-Type-Options: 'NosniFF'\n", false},
+      {"Double quoted", "X-Content-Type-Options: \"nosniFF\"\n", false},
+      {"Missing X-", "Content-Type-Options: nosniff\n", false},
+  };
+
+  for (const auto& test_case : kTestCases) {
+    SCOPED_TRACE(test_case.description);
+    PerFactoryState per_factory_state;
+    auto analyzer =
+        std::make_unique<OpaqueResponseBlockingAnalyzer>(&per_factory_state);
+
+    auto response = CreateResponse(
+        "HTTP/1.1 200 OK\n"
+        "Content-Type: application/json\n" +
+        std::string(test_case.header));
+    // Use application/json to ensure that nosniff detection leads to an
+    // immediate block.
+    response->mime_type = "application/json";
+
+    ResponseAnalyzer::Decision decision =
+        analyzer->Init(GURL("https://target.test"),
+                       url::Origin::Create(GURL("https://initiator.test")),
+                       mojom::RequestMode::kNoCors,
+                       mojom::RequestDestination::kEmpty, *response);
+
+    if (test_case.expected_nosniff) {
+      EXPECT_EQ(ResponseAnalyzer::Decision::kBlock, decision);
+    } else {
+      EXPECT_EQ(ResponseAnalyzer::Decision::kSniffMore, decision);
+    }
+  }
+}
+
 }  // namespace network::orb
Loading diff…

Original Bug Report

reported by [email protected]

ORB bypass: HasNoSniff fails on duplicate X-Content-Type-Options headers leaking JSON arrays

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 without the Chrome Security team.

Overview: ORB’s HasNoSniff incorrectly handles duplicate X-Content-Type-Options headers due to concatenation by GetNormalizedHeader, causing the literal comparison with “nosniff” to fail. This bypasses the immediate block for application/json, and because SniffForJSON only recognizes dictionaries, sensitive JSON arrays can bypass ORB and leak into a cross-origin renderer process. This exposes the data to potential Spectre side-channel attacks.

Affected files:

  • services/network/orb/orb_impl.cc
  • services/network/orb/orb_sniffers.cc

Estimated timestamp from git blame: 2024-10-18

Summary

A logic flaw in Opaque Response Blocking (ORB) prevents the correct recognition of the X-Content-Type-Options: nosniff header when multiple instances are present in an HTTP response. This causes ORB to fall back to content sniffing for MIME types like application/json that should be blocked immediately. Because ORB’s JSON sniffer currently only recognizes JSON dictionaries (starting with {), JSON arrays bypass the sniffing phase and are erroneously allowed into the renderer process, where they are vulnerable to Spectre-style exfiltration.

Technical Details

1. Header Parsing Flaw in HasNoSniff

In services/network/orb/orb_impl.cc, the HasNoSniff function checks for the nosniff directive by calling response.headers->GetNormalizedHeader("x-content-type-options").

If a server or intermediary proxy sends multiple X-Content-Type-Options: nosniff headers, GetNormalizedHeader concatenates them with a comma and space, resulting in the string "nosniff, nosniff".

HasNoSniff then performs a strict case-insensitive string comparison: base::EqualsCaseInsensitiveASCII(nosniff_header, "nosniff"). Because "nosniff, nosniff" is not strictly equal to "nosniff", the function incorrectly returns false.

2. Bypass of Immediate ORB Block

In OpaqueResponseBlockingAnalyzer::Init, when processing MimeType::kJson, the code relies on is_no_sniff_header_present_ (the result of HasNoSniff) to immediately block the resource. Since it is false, the immediate block is bypassed, and the analyzer returns Decision::kSniffMore.

3. Sniffing Failure for JSON Arrays

The response body is then passed to OpaqueResponseBlockingAnalyzer::Sniff. The JSON sniffer, SniffForJSON (services/network/orb/orb_sniffers.cc), explicitly expects the first non-whitespace character to be { to identify a JSON object. If the sensitive data is a JSON array starting with [, the sniffer returns kNo.

Consequently, no sniffer positively identifies the blocked content, and HandleEndOfSniffableResponseBody defaults to Decision::kAllow. The cross-origin JSON array is delivered to the attacker’s renderer process memory space.

Potential Attack Steps

Note: These steps are based on static analysis; an active PoC has not been executed.

  1. Target Selection: The attacker targets a sensitive JSON array hosted at https://victim.example/data.json (e.g., [{"secret": "data"}]).
  2. Server Condition: The victim server, or a CDN/proxy in front of it, must be configured in a way that duplicates the X-Content-Type-Options: nosniff header.
  3. Cross-Origin Request: The attacker triggers a no-cors fetch request to the target from a malicious page: fetch('https://victim.example/data.json', {mode: 'no-cors'}).
  4. ORB Bypass: Due to the duplicate headers, HasNoSniff returns false. The [ character causes SniffForJSON to fail. ORB allows the resource.
  5. Exfiltration: The raw JSON array data is now in the attacker’s renderer process memory. The attacker uses a local transient-execution side-channel attack (like Spectre v1) in JavaScript to read the memory and exfiltrate the victim’s data.

Suggested Fix

Update HasNoSniff in services/network/orb/orb_impl.cc to properly parse the normalized header string. It should split the string by commas, strip whitespace, and check if the first token is "nosniff". This aligns with the Fetch specification and how it is implemented elsewhere, such as in ParseContentTypeOptionsHeader in third_party/blink/renderer/platform/network/http_parsers.cc.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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