Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in SanitizerAPI
DescriptionInappropriate implementation in SanitizerAPI
ComponentSanitizerAPI
Bug ClassLogic Error
Tracker492963096
Fix commiteeaa42aa25b1 (chromium/src) +189/-62
CISA KEVNot listed
Crediteds3zer0
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/html/parser/html_construction_site.cc
modified
if
third_party/blink/renderer/core/sanitizer/sanitizer.cc
modified

Files Changed

  • third_party/blink/renderer/core/html/parser/html_construction_site.cc
  • third_party/blink/renderer/core/sanitizer/sanitizer.cc
  • third_party/blink/renderer/core/sanitizer/sanitizer.h
  • third_party/blink/web_tests/external/wpt/sanitizer-api/sanitizer-custom-elements-is.tentative-expected.txt
  • third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements-expected.txt
  • third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-with-custom-elements.html
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
Loading diff…

Regression Test / PoC

shipped with the fix
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>
Loading diff…

Original Bug Report

reported by ch...@gmail.com

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?

https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/element.cc;l=9200


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:

  1. Parse → custom element created → attributeChangedCallback(name, old, ATTACKER_VALUE) fires
  2. Sanitize → custom element stripped from DOM
  3. Insert → clean fragment inserted

The callbacks at step 1 have full access to window, document, and DOM, with attacker-controlled attribute values.

Reproduction Steps

  1. Open Chrome 146+ (stable)
  2. Open DevTools Console
  3. 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!)
  1. Observe: document.title changed, script executed, but output HTML is clean.
  2. 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

  1. 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.)
  2. Target page uses setHTML() to sanitize user-provided HTML
  3. 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.cookie is accessible from the callback
  • DOM manipulation: Arbitrary DOM modification including script injection
  • Network access: fetch() and XMLHttpRequest are 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

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

View on issue tracker