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

Overview

Medium
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in SanitizerAPI
DescriptionInappropriate implementation in SanitizerAPI
ComponentSanitizerAPI
Sub-componentSanitizer
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker492963096
Fix commiteeaa42aa25b1 (chromium/src) +189/-62
CWECWE-79 (Cross-site scripting)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N
CISA KEVNot listed
Crediteds3zer0
Reported2026-03-15 (per vendor advisory)
Fix landed2026-06-01 (public git)
Disclosed2026-05-05

Background

Sanitizer API
the web platform API (setHTML/setHTMLUnsafe with a sanitizer config) that parses untrusted HTML and strips disallowed elements/attributes; the streaming variant (StreamingSanitizer) filters during parsing.
`is=` attribute / customized built-in elements
an HTML parser feature where <div is="x-div"> upgrades a built-in element into a registered custom element, running its constructor and connectedCallback.
`HTMLConstructionSite`
the Blink parser component that creates elements from tokens; it reads the is= attribute at creation time to decide custom-element processing.
`CustomElementRegistry` upgrade
once the parser marks an element as a custom element (because of is=), the element is later upgraded/constructed even if the attribute is subsequently removed.

Root Cause Analysis

When HTML is parsed, the construction site reads the token’s is= attribute and records custom-element state on the created element; the streaming Sanitizer only ran its attribute filtering afterward, removing the is= attribute from the finished element. Because the element had already been marked as a custom element during construction, removing is= did not undo the pending upgrade, so the customized built-in element was still constructed and connected — meaning a sanitizer that was supposed to strip is= failed to prevent the resulting custom-element code (constructor/connectedCallback) from running.

The fix threads the sanitizer decision into element creation: it adds Sanitizer::AllowIsAttribute(element_name) (and StreamingSanitizer::AllowIsAttribute), and in HTMLConstructionSite it now computes sanitizer_allows_is_attribute = !sanitizer_ || sanitizer_->AllowIsAttribute(tag_name) and treats the is= value as g_null_atom when the sanitizer would strip it, so the element is never marked as a customized built-in in the first place. The shared decision logic is refactored into Sanitizer::KeepAttribute, reused by both SanitizeElement’s per-attribute loop and the new AllowIsAttribute query, keeping the “would this attribute survive sanitization” rule consistent. The removed *-expected.txt failure baselines (which previously recorded is= surviving) and the rewritten WPT confirm the intended behavior: is=-driven custom elements must not construct/connect when is= is blocked.

Key insight
The core mistake was ordering: the parser committed custom-element state from is= before the Sanitizer could strip it, so later attribute removal left an already-upgraded element; the fix consults AllowIsAttribute during element creation and pretends is= is absent whenever the sanitizer would remove it.

Attack Path

  1. Supply sanitized HTML with `is=` Attacker-controlled markup such as <div is='x-div'> is passed to setHTML with a sanitizer config that removes the is= attribute (e.g. removeAttributes:["is"] or default config where is= is not allowed).
  2. Parser marks custom element During parsing, HTMLConstructionSite reads is= and flags the element as a customized built-in before sanitization runs.
  3. Sanitizer strips attribute, not state The streaming Sanitizer removes the is= attribute from the element, but the pending custom-element upgrade remains, so the element is still upgraded.
  4. Unexpected custom-element code runs The customized built-in’s constructor and connectedCallback execute despite the sanitizer having “removed” is=, defeating the author’s expectation that sanitized content runs no such callbacks (behavior the new WPT asserts should be constructed == 0, connected == 0).

Impact Assessment

An attacker whose HTML is processed through the Sanitizer API can get a customized built-in element’s JavaScript callbacks (constructor/connectedCallback) to run even when the sanitizer config strips is=, i.e. a sanitization bypass executing in the embedding page’s renderer context. The advisory rates this medium (“Inappropriate implementation in SanitizerAPI”) with no exploited-in-the-wild claim; the diff demonstrates unexpected custom-element construction, while any further escalation is (inferred). Preconditions: the victim application feeds untrusted markup to setHTML with a config intended to block is= and has a matching custom element defined.

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

Audit Directions

  • Parse-time state vs. post-parse sanitization
    Audit features where the HTML parser commits irreversible state (custom-element upgrade, script marking, form association) that a later sanitization pass only partially unwinds by editing attributes.
  • Attribute removal that does not revert side effects
    Flag places where removing an attribute (is=, src, event handlers) is assumed to neutralize behavior already triggered by that attribute during construction.
  • Consistency between decision paths
    When sanitizer logic is consulted in two places (runtime stripping and a parser-time pre-check), verify both call the same helper (KeepAttribute) so allow/remove/data-/default semantics cannot diverge.
From eeaa42aa25b1066049bc10ec09fa3ff4b91b6f2b Mon Sep 17 00:00:00 2001
From: Daniel Vogelheim <[email protected]>
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 <[email protected]>
Commit-Queue: Daniel Vogelheim <[email protected]>
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 [email protected]

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