CVE-2026-7939
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/html/parser/html_construction_site.cc |
modified | |
ifthird_party/blink/renderer/core/sanitizer/sanitizer.cc |
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/sanitizer-api/sanitizer-custom-elements-is.tentative-expected.txtthird_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements-expected.txtthird_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements.html
Patch
From eeaa42aa25b1066049bc10ec09fa3ff4b91b6f2b Mon Sep 17 00:00:00 2001
From: Daniel Vogelheim <vogelheim@chromium.org>
Date: Mon, 01 Jun 2026 08:57:09 -0700
Subject: [PATCH] [Sanitizer] Remove custom element state, when created by is=
The HTML parse will process the is= attribute and put the element into
custom element state. When later on the is= attribute is removed by the
Sanitizer, the element is still upgraded because it's been marked as
a custom element.
This change prevents the is=-related custom element to be processed,
when the streaming Sanitizer will remove it.
Bug: 492963096, 513844247, 517171036
Change-Id: I7b10f55b6dd9d568336c731ee8a8c2a52254371f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7883146
Reviewed-by: Noam Rosenthal <nrosenthal@google.com>
Commit-Queue: Daniel Vogelheim <vogelheim@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1639435}
---
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 fb738abf..2b1f3be 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
@@ -1113,7 +1113,10 @@
parser_content_policy_ !=
kAllowScriptingContentAndMarkAsParserInserted);
HTMLScriptElement* element = nullptr;
- if (const auto* is_attribute = token->GetAttributeItem(html_names::kIsAttr)) {
+ const auto* is_attribute = token->GetAttributeItem(html_names::kIsAttr);
+ bool sanitizer_allows_is_attribute =
+ !sanitizer_ || sanitizer_->AllowIsAttribute(html_names::kScriptTag);
+ if (is_attribute && sanitizer_allows_is_attribute) {
element = To<HTMLScriptElement>(OwnerDocumentForCurrentNode().CreateElement(
html_names::kScriptTag, flags, is_attribute->Value(),
CustomElementRegistry::DefaultRegistry(OwnerDocumentForCurrentNode())));
@@ -1289,7 +1292,13 @@
: QualifiedName(g_null_atom, token->GetName(), namespace_uri));
// "5. Let is be the value of the "is" attribute in the given token ..." etc.
const Attribute* is_attribute = token->GetAttributeItem(html_names::kIsAttr);
- const AtomicString& is = is_attribute ? is_attribute->Value() : g_null_atom;
+ // If sanitizer_ is set and if santizer_ would not allow the "is" attribute,
+ // then we will just pretend to not have seen it.
+ bool sanitizer_allows_is_attribute =
+ !sanitizer_ || sanitizer_->AllowIsAttribute(tag_name);
+ const AtomicString& is = (is_attribute && sanitizer_allows_is_attribute)
+ ? is_attribute->Value()
+ : g_null_atom;
// "6. Let registry be the result of looking up a custom element registry
// given intended parent."
CustomElementRegistry* registry = custom_element_registry_;
diff --git a/third_party/blink/renderer/core/sanitizer/sanitizer.cc b/third_party/blink/renderer/core/sanitizer/sanitizer.cc
index f12f266..ecb5d61c 100644
--- a/third_party/blink/renderer/core/sanitizer/sanitizer.cc
+++ b/third_party/blink/renderer/core/sanitizer/sanitizer.cc
@@ -769,6 +769,27 @@
}
}
+bool Sanitizer::KeepAttribute(const SanitizerNameSet* allow_per_element,
+ const SanitizerNameSet* remove_per_element,
+ const QualifiedName& attribute) const {
+ bool keep = false;
+ if (remove_per_element && remove_per_element->Contains(attribute)) {
+ keep = false;
+ } else if (allow_attrs_ && allow_attrs_->Contains(attribute)) {
+ keep = true;
+ } else if (allow_per_element && allow_per_element->Contains(attribute)) {
+ keep = true;
+ } else if (remove_attrs_ && remove_attrs_->Contains(attribute)) {
+ keep = false;
+ } else if (allow_attrs_ && attribute.NamespaceURI().IsNull() &&
+ attribute.LocalName().starts_with("data-")) {
+ keep = data_attrs_ == SanitizerBoolWithAbsence::kTrue;
+ } else {
+ keep = !allow_attrs_ && !allow_per_element;
+ }
+ return keep;
+}
+
void Sanitizer::SanitizeElement(Element* element, Mode safe) const {
// https://wicg.github.io/sanitizer-api/#sanitize-core, Step 1.5.8 + 1.5.9.1-4
//
@@ -789,21 +810,7 @@
? nullptr
: &remove_per_element_iter->value;
for (const QualifiedName& name : element->getAttributeQualifiedNames()) {
- bool keep = false;
- if (remove_per_element && remove_per_element->Contains(name)) {
- keep = false;
- } else if (allow_attrs_ && allow_attrs_->Contains(name)) {
- keep = true;
- } else if (allow_per_element && allow_per_element->Contains(name)) {
- keep = true;
- } else if (remove_attrs_ && remove_attrs_->Contains(name)) {
- keep = false;
- } else if (allow_attrs_ && name.NamespaceURI().IsNull() &&
- name.LocalName().starts_with("data-")) {
- keep = data_attrs_ == SanitizerBoolWithAbsence::kTrue;
- } else {
- keep = !allow_attrs_ && !allow_per_element;
- }
+ bool keep = KeepAttribute(allow_per_element, remove_per_element, name);
if (!keep) {
element->removeAttribute(name);
}
@@ -1410,6 +1417,23 @@
return true;
}
+bool Sanitizer::AllowIsAttribute(const QualifiedName& element_name) const {
+ const auto allow_per_element_iter =
+ allow_attrs_per_element_.find(element_name);
+ const SanitizerNameSet* allow_per_element =
+ (allow_per_element_iter == allow_attrs_per_element_.end())
+ ? nullptr
+ : &allow_per_element_iter->value;
+ const auto remove_per_element_iter =
+ remove_attrs_per_element_.find(element_name);
+ const SanitizerNameSet* remove_per_element =
+ (remove_per_element_iter == remove_attrs_per_element_.end())
+ ? nullptr
+ : &remove_per_element_iter->value;
+ return KeepAttribute(allow_per_element, remove_per_element,
+ html_names::kIsAttr);
+}
+
void StreamingSanitizer::DidParseDocument(Document* document) {
Element* root = document->documentElement();
CHECK(root);
diff --git a/third_party/blink/renderer/core/sanitizer/sanitizer.h b/third_party/blink/renderer/core/sanitizer/sanitizer.h
index 5bddd03..c0d39be 100644
--- a/third_party/blink/renderer/core/sanitizer/sanitizer.h
+++ b/third_party/blink/renderer/core/sanitizer/sanitizer.h
@@ -133,6 +133,7 @@
Action SanitizeSingleNode(Node* node, Mode safe) const;
bool ShouldReplaceNodeWithChildren(Node* node) const;
void ProcessElement(Element* element, Mode safe) const;
+ bool AllowIsAttribute(const QualifiedName& element_name) const;
// Helper for Create: Convert from IDL representation to internal.
bool setFrom(const SanitizerConfig*, bool allowCommentsAndDataAttributes);
@@ -150,6 +151,9 @@
void SanitizeJavascriptNavigationAttributes(Element* element,
Mode safe) const;
void SanitizeTemplate(Node* node, Mode safe) const;
+ bool KeepAttribute(const SanitizerNameSet* allow_per_element,
+ const SanitizerNameSet* remove_per_element,
+ const QualifiedName& attribute) const;
// Helpers for get(): Convert from internal to IDL representation.
QualifiedName getFrom(const String& name, const String& namespaceURI) const;
@@ -199,6 +203,14 @@
bool ShouldReplaceWithChildren(Node* node) const {
return sanitizer_->ShouldReplaceNodeWithChildren(node);
}
+
+ // Special treaming for parser-processed HTML feature:
+ // This determines whether an is= attribute would be allowed on this
+ // element QName. The parser wants to know this before creating the element.
+ bool AllowIsAttribute(const QualifiedName& element_name) const {
+ return sanitizer_->AllowIsAttribute(element_name);
+ }
+
void DidParseDocument(Document* document);
void Trace(Visitor* visitor) const { visitor->Trace(sanitizer_); }
diff --git a/third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-is.tentative-expected.txt b/third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-is.tentative-expected.txt
deleted file mode 100644
index f446a5d..0000000
--- a/third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-is.tentative-expected.txt
+++ /dev/null
@@ -1,11 +0,0 @@
-This is a testharness.js-based test.
-[FAIL] The is= attribute is removed by the default sanitizer config.
- assert_equals: expected "<div>hello</div>" but got "<div is=\\"foo-bar\\">hello</div>"
-[FAIL] The is= attribute is removed by the global removeAttributes
- assert_equals: expected "<div>hello</div>" but got "<div is=\\"foo-bar\\">hello</div>"
-[FAIL] The is= attribute is removed by the local removeAttributes
- assert_equals: expected "<div>hello</div>" but got "<div is=\\"foo-bar\\">hello</div>"
-[FAIL] The is= attribute is removed by the local missing attributes
- assert_equals: expected "<div>hello</div>" but got "<div is=\\"foo-bar\\">hello</div>"
-Harness: the test ran to completion.
-
diff --git a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements-expected.txt b/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements-expected.txt
deleted file mode 100644
index c063c997..0000000
--- a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements-expected.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-This is a testharness.js-based test.
-[FAIL] Scoped custom element registry still works.
- assert_true: expected true got false
-Harness: the test ran to completion.
-
diff --git a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements.html b/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements.html
index 830ce657..af9b84a 100644
--- a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements.html
+++ b/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements.html
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-is.tentative-expected.txt b/third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-is.tentative-expected.txt
deleted file mode 100644
index f446a5d..0000000
--- a/third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-is.tentative-expected.txt
+++ /dev/null
@@ -1,11 +0,0 @@
-This is a testharness.js-based test.
-[FAIL] The is= attribute is removed by the default sanitizer config.
- assert_equals: expected "<div>hello</div>" but got "<div is=\\"foo-bar\\">hello</div>"
-[FAIL] The is= attribute is removed by the global removeAttributes
- assert_equals: expected "<div>hello</div>" but got "<div is=\\"foo-bar\\">hello</div>"
-[FAIL] The is= attribute is removed by the local removeAttributes
- assert_equals: expected "<div>hello</div>" but got "<div is=\\"foo-bar\\">hello</div>"
-[FAIL] The is= attribute is removed by the local missing attributes
- assert_equals: expected "<div>hello</div>" but got "<div is=\\"foo-bar\\">hello</div>"
-Harness: the test ran to completion.
-
diff --git a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements-expected.txt b/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements-expected.txt
deleted file mode 100644
index c063c997..0000000
--- a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements-expected.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-This is a testharness.js-based test.
-[FAIL] Scoped custom element registry still works.
- assert_true: expected true got false
-Harness: the test ran to completion.
-
diff --git a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements.html b/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements.html
index 830ce657..af9b84a 100644
--- a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements.html
+++ b/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements.html
@@ -7,41 +7,139 @@
<body>
<div></div>
<script>
+window.constructed = 0;
+window.connected = 0;
+function cleanup() {
+ window.constructed = 0;
+ window.connected = 0;
+}
+customElements.define("x-probe", class extends HTMLElement {
+ constructor() { super(); window.constructed++; }
+ connectedCallback() { window.connected++; }
+});
+
+// Test cases involving named custom elements.
test(t => {
- customElements.define('x-removed', class extends HTMLElement {
- static get observedAttributes() { return ['data-attr']; }
- attributeChangedCallback() { assert_unreached("callback called"); }
- });
- document.body.firstElementChild.setHTML(
- "<x-removed data-attr='bla'></x-removed>");
+ t.add_cleanup(cleanup);
+ document.body.firstElementChild.setHTML("<x-probe></x-probe>");
+ assert_equals(window.constructed, 0);
+ assert_equals(window.connected, 0);
}, "Custom element that is removed during sanitization.");
test(t => {
- let flag = false;
- customElements.define('x-allowed', class extends HTMLElement {
- static get observedAttributes() { return ['data-attr']; }
- attributeChangedCallback() { flag = true; }
- });
- document.body.firstElementChild.setHTML(
- "<x-allowed data-attr='bla'></x-allowed>",
- {sanitizer: {elements: ["x-allowed"]}});
- assert_true(flag);
+ t.add_cleanup(cleanup);
+ document.body.firstElementChild.setHTML("<x-probe></x-probe>",
+ {sanitizer: {elements: ["x-probe"]}});
+ assert_equals(window.constructed, 1);
+ assert_equals(window.connected, 1);
}, "Custom element that is allowed.");
+// Test cases involving `is=`.
+customElements.define("x-div", class extends HTMLDivElement {
+ constructor() { super(); window.constructed++; }
+ connectedCallback() { window.connected++; }
+}, { extends: "div" });
+
test(t => {
- let flag = false;
- const registry = new CustomElementRegistry();
- registry.define('x-scoped', class extends HTMLElement {
- static get observedAttributes() { return ['data-attr']; }
- attributeChangedCallback() { flag = true; }
- });
- const div = document.createElement("div", {customElementRegistry: registry});
- div.setHTML(
- "<x-scoped data-attr='bla'></x-scoped>",
- {sanitizer: {elements: ["x-scoped"]}});
- assert_true(flag);
- assert_equals(div.firstElementChild.localName, "x-scoped");
- assert_equals(div.firstElementChild.customElementRegistry, registry);
-}, "Scoped custom element registry still works.");
+ t.add_cleanup(cleanup);
+ document.body.firstElementChild.setHTML("<div is='x-div'></div>");
+ assert_equals(window.constructed, 0);
+ assert_equals(window.connected, 0);
+}, "Custom element via is-attribute, where is attribute is not default-allowed.");
+
+test(t => {
+ t.add_cleanup(cleanup);
+ document.body.firstElementChild.setHTML("<div is='x-div'></div>",
+ {sanitizer: {elements:["div"], removeAttributes:["is"]}});
+ assert_equals(window.constructed, 0);
+ assert_equals(window.connected, 0);
+}, "Custom element via is-attribute, where is attribute is removed.");
+
+test(t => {
+ t.add_cleanup(cleanup);
+ document.body.firstElementChild.setHTML("<div is='x-div'></div>",
+ {sanitizer: {elements:["div", "x-div"], removeAttributes:["is"]}});
+ assert_equals(window.constructed, 0);
+ assert_equals(window.connected, 0);
+}, "Custom element via is-attribute, where is attribute is removed but the custom element would have been allowed.");
+
+test(t => {
+ t.add_cleanup(cleanup);
+ document.body.firstElementChild.setHTML("<x-probe is='x-div'></x-probe>",
+ {sanitizer: {elements:["div", "x-probe"], removeAttributes:["is"]}});
+ assert_equals(window.constructed, 1);
+ assert_equals(window.connected, 1);
+}, "Custom element with an is-attribute, where the is=-attribute is blocked.");
+
+test(t => {
+ t.add_cleanup(cleanup);
+ document.body.firstElementChild.setHTML("<div is='x-div'></div>",
+ {sanitizer: {elements:["p", "x-div"], attributes:["is", "data-attr"]}});
+ assert_equals(window.constructed, 0);
+ assert_equals(window.connected, 0);
+}, "Custom element via is-attribute, where is attribute is allowed, but the element is not.");
+
+test(t => {
+ t.add_cleanup(cleanup);
+ document.body.firstElementChild.setHTML("<div is='x-div'></div>",
+ {sanitizer: {removeElements:["div"], attributes:["is", "data-attr"]}});
+ assert_equals(window.constructed, 0);
+ assert_equals(window.connected, 0);
+}, "Custom element via is-attribute, where is attribute is allowed, but the element is removed.");
+
+test(t => {
+ t.add_cleanup(cleanup);
+ document.body.firstElementChild.setHTML("<div is='x-div'></div>",
+ {sanitizer: {replaceWithChildrenElements:["div"], attributes:["is", "data-attr"]}});
+ assert_equals(window.constructed, 0);
+ assert_equals(window.connected, 0);
+}, "Custom element via is-attribute, where is attribute is allowed, but the element is replaced.");
+
+test(t => {
+ t.add_cleanup(cleanup);
+ document.body.firstElementChild.setHTMLUnsafe("<div is='x-div'></div>",
+ {sanitizer: "default"});
+ assert_equals(window.constructed, 0);
+ assert_equals(window.connected, 0);
+}, "Custom element via is-attribute with unsafe, where is attribute is not default-allowed.");
+
+test(t => {
+ t.add_cleanup(cleanup);
+ document.body.firstElementChild.setHTML(
+ "<div is='x-div'></div>",
+ {sanitizer: {elements:["p", "div"], attributes:["is"]}});
+ assert_equals(window.constructed, 1);
+ assert_equals(window.connected, 1);
+}, "Sanity check, that is= processing still works when allowed.");
+
+// Test cases involving `is=` with <html> or <script>
+customElements.define("x-script", class extends HTMLScriptElement {
+ constructor() { super(); window.constructed++; }
+ connectedCallback() { window.connected++; }
+}, {extends: "script"});
+
+test(t => {
+ t.add_cleanup(cleanup);
+ document.body.firstElementChild.setHTMLUnsafe(
+ "<script is='x-script'>2+2</sc" + "ript>",
+ {sanitizer: {elements: ["script"],removeAttributes:["is"]}});
+ assert_equals(window.constructed, 0);
+ assert_equals(window.connected, 0);
+}, "Custom element via is-attribute on script element, where is attribute is not allowed.");
+
+customElements.define("x-html", class extends HTMLHtmlElement {
+ constructor() { super(); window.constructed++; }
+ connectedCallback() { window.connected++; }
+}, {extends: "html"});
+
+test(t => {
+ t.add_cleanup(cleanup);
+ document.body.firstElementChild.setHTMLUnsafe(
+ "<html is='x-html'></html>",
+ {sanitizer: {elements: ["html"],removeAttributes:["is"]}});
+ assert_equals(window.constructed, 0);
+ assert_equals(window.connected, 0);
+}, "Custom element via is-attribute on html element, where is attribute is not default-allowed.");
+
</script>
</body>
Original Bug Report
setHTML() Sanitizer API bypass via custom element callback execution during parsing
Report description
setHTML() Sanitizer API bypass via custom element callback execution during parsing
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
The problem
Please describe the technical details of the vulnerability
The problem
Summary
Element.setHTML() in Chrome 146 fires custom element lifecycle callbacks (attributeChangedCallback, connectedCallback) during HTML fragment parsing, before the Sanitizer processes the DOM tree. An attacker who supplies HTML containing custom elements registered on the target page can execute arbitrary JavaScript through these callbacks, while the sanitized output appears completely clean.
Firefox 148 (which also ships setHTML()) is not affected — it correctly suppresses custom element callbacks during sanitization parsing. Chrome’s own DOMParser is also not affected.
Root Cause
In element.cc:9200-9211, CustomElementRegistryForInnerHTML() returns the live document’s CustomElementRegistry:
CustomElementRegistry* CustomElementRegistryForInnerHTML(Element* element) {
CustomElementRegistry* registry =
element->GetDocument().customElementRegistry();
// ...
return registry; // Returns LIVE registry
}
This registry is passed to ParseHTMLFragment() at line 9238:
DocumentFragment* fragment =
ParseHTMLFragment(html,
{/* ... */
.registry = CustomElementRegistryForInnerHTML(this)},
options, exception_state);
The parser creates custom elements using this registry, triggering lifecycle callbacks synchronously during parsing. Only after ParseHTMLFragment returns does SanitizerAPI::SanitizeInternal() (sanitizer_api.cc:48) run to strip unsafe elements.
The temporal ordering is:
- Parse → custom element created →
attributeChangedCallback(name, old, ATTACKER_VALUE)fires - Sanitize → custom element stripped from DOM
- Insert → clean fragment inserted
The callbacks at step 1 have full access to window, document, and DOM, with attacker-controlled attribute values.
Reproduction Steps
- Open Chrome 146+ (stable)
- Open DevTools Console
- Paste and run:
// Register a custom element (simulates a web component framework)
customElements.define('x-widget', class extends HTMLElement {
static get observedAttributes() { return ['data-x']; }
attributeChangedCallback(name, old, val) {
// This fires DURING setHTML, BEFORE sanitization
document.title = 'PWNED: ' + val;
const s = document.createElement('script');
s.textContent = 'window._proof = "callback executed"';
document.head.appendChild(s);
}
});
// Sanitize untrusted HTML with setHTML
const div = document.createElement('div');
document.body.appendChild(div);
div.setHTML('<x-widget data-x="attacker-controlled"></x-widget><p>safe</p>');
// Verify
console.log('Title:', document.title); // "PWNED: attacker-controlled"
console.log('Script ran:', window._proof); // "callback executed"
console.log('Output HTML:', div.innerHTML); // "<p>safe</p>" (clean!)
- Observe:
document.titlechanged, script executed, but output HTML is clean. - Repeat in Firefox 148 — callbacks do not fire.
Additional Vector: is= Attribute
<div is="x-custom"> survives the default sanitizer (since <div> is an allowed element). If a customized built-in element is registered, its connectedCallback fires:
customElements.define('x-div', class extends HTMLDivElement {
connectedCallback() { document.title = 'PWNED-VIA-IS'; }
}, { extends: 'div' });
div.setHTML('<div is="x-div">innocent</div>');
// Output: <div is="x-div">innocent</div> (element survives!)
// document.title is now "PWNED-VIA-IS"
Verified Capabilities During Callback
| Primitive | Chrome 146 | Firefox 148 |
|---|---|---|
attributeChangedCallback fires |
YES | NO |
Script injection (document.head.appendChild(script)) |
YES | NO |
DOM modification (document.title, element creation) |
YES | NO |
Cookie access (document.cookie) |
YES | NO |
Network requests (fetch()) |
YES | NO |
| Output HTML appears clean | YES | YES |
Impact analysis
Who Can Exploit
Any web attacker who can supply HTML content to a setHTML() call on a target page. This is the intended use case of the Sanitizer API — setHTML() was designed specifically to safely handle untrusted HTML input (e.g., user-generated content, rich text editors, HTML email rendering).
Preconditions
- Target page registers custom elements with
observedAttributes— this is standard practice in modern web apps using component frameworks (Lit, Stencil, FAST, Angular Elements, Shoelace, Microsoft FAST, etc.) - Target page uses
setHTML()to sanitize user-provided HTML - Attacker discovers registered custom element names (visible in page source)
What the Attacker Gains
- Equivalent to XSS: Full JavaScript execution in the page’s origin context
- Cookie/session theft:
document.cookieis accessible from the callback - DOM manipulation: Arbitrary DOM modification including script injection
- Network access:
fetch()andXMLHttpRequestare callable - Invisibility: The sanitized output HTML is completely clean — the attack leaves no trace in the DOM
Severity Justification
This violates the fundamental security invariant of the Sanitizer API: that setHTML() produces safe output without executing attacker-controlled code. Developers using setHTML() as a security boundary against XSS are vulnerable despite following the API’s intended usage. Firefox 148 and Chrome’s own DOMParser correctly prevent this, confirming it is a Chrome implementation bug.
The cause
What version of Chrome have you found the security issue in?
146.0.7680.72 stable
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Security UI Spoofing
How would you like to be publicly acknowledged for your report?
s3zer0
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/element.cc;l=9200
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/element.cc;l=9238
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/sanitizer/sanitizer_api.cc;l=48