Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactScript injection in SanitizerAPI
DescriptionScript injection in SanitizerAPI
ComponentSanitizerAPI
Bug ClassLogic Error
Tracker496524586
Fix commit54315b858250 (chromium/src) +57/-1
CISA KEVNot listed
CreditedJungwoo Lee (@physicube) and Wongi Lee (@_qwerty_po)
Disclosed2026-05-12

Changed Functions

FunctionChangeNotes
TEST_F
third_party/blink/renderer/core/dom/document_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/dom/document.cc
  • third_party/blink/renderer/core/dom/document_test.cc
  • third_party/blink/renderer/core/html/parser/fragment_parser.cc
  • third_party/blink/renderer/core/sanitizer/sanitizer_api.cc
  • third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-tree-construction.tentative.html
From 54315b858250350774108ce6b986606e0d77efe7 Mon Sep 17 00:00:00 2001
From: Daniel Vogelheim <vogelheim@chromium.org>
Date: Thu, 02 Apr 2026 05:39:20 -0700
Subject: [PATCH] [Sanitizer] Ensure content is removed when an exception is thrown.

When the sanitize operation throws an exception, we don't clear the
parse result. Also, the callers will pass through the exception, but
will return the incomplete result to the callers.

Fixed: 496524586
Change-Id: I3a6402afacb5d5275f546dfdefdac5d270e96d1f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7705032
Reviewed-by: Noam Rosenthal <nrosenthal@google.com>
Reviewed-by: Joey Arhar <jarhar@chromium.org>
Commit-Queue: Daniel Vogelheim <vogelheim@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1609133}
---

diff --git a/third_party/blink/renderer/core/dom/document.cc b/third_party/blink/renderer/core/dom/document.cc
index 75dd7e4..9fea619a 100644
--- a/third_party/blink/renderer/core/dom/document.cc
+++ b/third_party/blink/renderer/core/dom/document.cc
@@ -10178,6 +10178,9 @@
                                  /*context_element*/ doc, /*root_element*/ doc,
                                  FragmentParserOptions(options),
                                  exception_state);
+  if (exception_state.HadException()) {
+    return nullptr;
+  }
   return doc;
 }
 
@@ -10192,6 +10195,9 @@
                                  /*context_element*/ doc, /*root_element*/ doc,
                                  FragmentParserOptions(options),
                                  exception_state);
+  if (exception_state.HadException()) {
+    return nullptr;
+  }
   return doc;
 }
 
diff --git a/third_party/blink/renderer/core/dom/document_test.cc b/third_party/blink/renderer/core/dom/document_test.cc
index 4c3a20a..d91c3a5 100644
--- a/third_party/blink/renderer/core/dom/document_test.cc
+++ b/third_party/blink/renderer/core/dom/document_test.cc
@@ -50,7 +50,14 @@
 #include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_core.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_testing.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_dom_exception.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_sanitizer_config.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_set_html_options.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_set_html_unsafe_options.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_throw_dom_exception.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_union_sanitizer_sanitizerconfig_sanitizerpresets.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_union_sanitizerelementnamespace_string.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_union_sanitizerelementnamespacewithattributes_string.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_union_string_trustedhtml.h"
 #include "third_party/blink/renderer/core/css/media_query_list_listener.h"
 #include "third_party/blink/renderer/core/css/media_query_matcher.h"
 #include "third_party/blink/renderer/core/css/style_engine.h"
@@ -2253,4 +2260,43 @@
 }
 #endif  // BUILDFLAG(IS_ANDROID)
 
+TEST_F(DocumentTest, ParseHTMLSanitizerException) {
+  // This is a regression test for https://crbug.com/496524586.
+
+  // SanitizerConfig equivalent to: {elements: ["div"], removeElements: ["div"]}
+  SanitizerConfig* config = SanitizerConfig::Create();
+  config->setElements({MakeGarbageCollected<
+      V8UnionSanitizerElementNamespaceWithAttributesOrString>("div")});
+  config->setRemoveElements(
+      {MakeGarbageCollected<V8UnionSanitizerElementNamespaceOrString>("div")});
+
+  {
+    DummyExceptionStateForTesting exception_state;
+    SetHTMLOptions* options = MakeGarbageCollected<SetHTMLOptions>();
+    options->setSanitizer(
+        MakeGarbageCollected<
+            V8UnionSanitizerOrSanitizerConfigOrSanitizerPresets>(config));
+    Document* doc =
+        Document::parseHTML(GetDocument().GetExecutionContext(), "test string",
+                            options, exception_state);
+    EXPECT_EQ(doc, nullptr);
+    EXPECT_TRUE(exception_state.HadException());
+  }
+
+  {
+    DummyExceptionStateForTesting exception_state;
+    SetHTMLUnsafeOptions* options =
+        MakeGarbageCollected<SetHTMLUnsafeOptions>();
+    options->setSanitizer(
+        MakeGarbageCollected<
+            V8UnionSanitizerOrSanitizerConfigOrSanitizerPresets>(config));
+    Document* doc = Document::parseHTMLUnsafe(
+        GetDocument().GetExecutionContext(),
+        MakeGarbageCollected<V8UnionStringOrTrustedHTML>("test string"),
+        options, exception_state);
+    EXPECT_EQ(doc, nullptr);
+    EXPECT_TRUE(exception_state.HadException());
+  }
+}
+
 }  // namespace blink
diff --git a/third_party/blink/renderer/core/html/parser/fragment_parser.cc b/third_party/blink/renderer/core/html/parser/fragment_parser.cc
index 69c78b4c..4aef43b8 100644
--- a/third_party/blink/renderer/core/html/parser/fragment_parser.cc
+++ b/third_party/blink/renderer/core/html/parser/fragment_parser.cc
@@ -253,6 +253,9 @@
                                    exception_state);
   }
 
+  if (exception_state.HadException()) {
+    return nullptr;
+  }
   return fragment;
 }
 
diff --git a/third_party/blink/renderer/core/sanitizer/sanitizer_api.cc b/third_party/blink/renderer/core/sanitizer/sanitizer_api.cc
index afa2c4e..cc815ed 100644
--- a/third_party/blink/renderer/core/sanitizer/sanitizer_api.cc
+++ b/third_party/blink/renderer/core/sanitizer/sanitizer_api.cc
@@ -68,8 +68,8 @@
 
   const Sanitizer* sanitizer =
       SanitizerFromOptions(options, mode, exception_state);
-
   if (exception_state.HadException()) {
+    root_element->setTextContent("");
     return;
   }
 
diff --git a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-tree-construction.tentative.html b/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-tree-construction.tentative.html
index 359560d..985b18eb 100644
--- a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-tree-construction.tentative.html
+++ b/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-tree-construction.tentative.html
@@ -24,6 +24,7 @@
               assert_equals(
                 error.name, testcase.error,
                 `Expect exception ${testcase.error}, but got ${error}.`);
+              assert_equals(div.firstChild, null);
               return;  // Early return in order to not trigger the subsequent
                        // assertions.
             }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/dom/document_test.cc b/third_party/blink/renderer/core/dom/document_test.cc
index 4c3a20a..d91c3a5 100644
--- a/third_party/blink/renderer/core/dom/document_test.cc
+++ b/third_party/blink/renderer/core/dom/document_test.cc
@@ -50,7 +50,14 @@
 #include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_core.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_testing.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_dom_exception.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_sanitizer_config.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_set_html_options.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_set_html_unsafe_options.h"
 #include "third_party/blink/renderer/bindings/core/v8/v8_throw_dom_exception.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_union_sanitizer_sanitizerconfig_sanitizerpresets.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_union_sanitizerelementnamespace_string.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_union_sanitizerelementnamespacewithattributes_string.h"
+#include "third_party/blink/renderer/bindings/core/v8/v8_union_string_trustedhtml.h"
 #include "third_party/blink/renderer/core/css/media_query_list_listener.h"
 #include "third_party/blink/renderer/core/css/media_query_matcher.h"
 #include "third_party/blink/renderer/core/css/style_engine.h"
@@ -2253,4 +2260,43 @@
 }
 #endif  // BUILDFLAG(IS_ANDROID)
 
+TEST_F(DocumentTest, ParseHTMLSanitizerException) {
+  // This is a regression test for https://crbug.com/496524586.
+
+  // SanitizerConfig equivalent to: {elements: ["div"], removeElements: ["div"]}
+  SanitizerConfig* config = SanitizerConfig::Create();
+  config->setElements({MakeGarbageCollected<
+      V8UnionSanitizerElementNamespaceWithAttributesOrString>("div")});
+  config->setRemoveElements(
+      {MakeGarbageCollected<V8UnionSanitizerElementNamespaceOrString>("div")});
+
+  {
+    DummyExceptionStateForTesting exception_state;
+    SetHTMLOptions* options = MakeGarbageCollected<SetHTMLOptions>();
+    options->setSanitizer(
+        MakeGarbageCollected<
+            V8UnionSanitizerOrSanitizerConfigOrSanitizerPresets>(config));
+    Document* doc =
+        Document::parseHTML(GetDocument().GetExecutionContext(), "test string",
+                            options, exception_state);
+    EXPECT_EQ(doc, nullptr);
+    EXPECT_TRUE(exception_state.HadException());
+  }
+
+  {
+    DummyExceptionStateForTesting exception_state;
+    SetHTMLUnsafeOptions* options =
+        MakeGarbageCollected<SetHTMLUnsafeOptions>();
+    options->setSanitizer(
+        MakeGarbageCollected<
+            V8UnionSanitizerOrSanitizerConfigOrSanitizerPresets>(config));
+    Document* doc = Document::parseHTMLUnsafe(
+        GetDocument().GetExecutionContext(),
+        MakeGarbageCollected<V8UnionStringOrTrustedHTML>("test string"),
+        options, exception_state);
+    EXPECT_EQ(doc, nullptr);
+    EXPECT_TRUE(exception_state.HadException());
+  }
+}
+
 }  // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-tree-construction.tentative.html b/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-tree-construction.tentative.html
index 359560d..985b18eb 100644
--- a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-tree-construction.tentative.html
+++ b/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-tree-construction.tentative.html
@@ -24,6 +24,7 @@
               assert_equals(
                 error.name, testcase.error,
                 `Expect exception ${testcase.error}, but got ${error}.`);
+              assert_equals(div.firstChild, null);
               return;  // Early return in order to not trigger the subsequent
                        // assertions.
             }
Loading diff…

Original Bug Report

reported by qw...@gmail.com

setHTML() fails open on invalid SanitizerConfig, inserting unsanitized HTML with active scripts into the live DOM


Report description

setHTML() fails open on invalid SanitizerConfig, inserting unsanitized HTML with active scripts into the live DOM


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://chromium.googlesource.com/chromium/src


The problem

Please describe the technical details of the vulnerability

Summary

Element.setHTML() fails open on invalid SanitizerConfig, inserting unsanitized HTML with active scripts into the live DOM.

When Sanitizer::Create throws a TypeError due to an invalid config (e.g., both elements and removeElements specified), the parsed but unsanitized DocumentFragment is inserted into the DOM. Event handler attributes such as ontoggle and onerror execute JavaScript immediately after insertion.

Chrome Version

  • Tested on Chrome 146.0.7680.164 (Official Build, 64-bit), Linux x86_64
  • Affected on All Chrome versions with the Sanitizer API enabled

Root Cause

SanitizeSafeInternal does not clear fragment on Sanitizer creation failure

File: third_party/blink/renderer/core/sanitizer/sanitizer_api.cc

When SanitizerFromSafeOptions fails, the function returns without clearing the fragment at lines 107–109:

if (exception_state.HadException()) {
    return;  // BUG: does NOT clear root_element
}

The earlier error path in the same function at lines 90–92 correctly clears it:

if (exception_state.HadException()) {
    root_element->setTextContent("");  // CORRECT: clears unsanitized content
    return;
}

The unsanitized fragment is unconditionally inserted into the live DOM. The TypeError propagates to JS, but the DOM is already modified and event handlers have already fired.

Reproduction

Sanitizer::setFrom() returns false when the config is invalid per spec:

  • Both elements and removeElements present (sanitizer.cc:1170)
  • Both attributes and removeAttributes present (sanitizer.cc:1175)
  • elements and replaceWithChildrenElements overlap (sanitizer.cc:1184)
  • Duplicate entries in any list (sanitizer.cc:1058)

Sanitizer::Create then throws TypeError at sanitizer.cc:65, entering the fail-open path.

Example

var el = document.createElement('div');
document.body.appendChild(el);
try {
  el.setHTML(
    '<details open ontoggle="alert(document.domain)"><summary>x</summary></details>' +
    '<img src=x onerror="alert(\'onerror XSS\')">',
    { sanitizer: { elements: ['div'], removeElements: ['span'] } }
  );
} catch(e) { console.log(e.message); }
console.log(el.innerHTML);

Expected: TypeError thrown, element remains empty and no script execution.

Actual: TypeError thrown but element contains unsanitized HTML. alert(document.domain) fires via ontoggle. alert('onerror XSS') fires via onerror. <script>, <iframe>, javascript: URLs all survive in the DOM.

Suggested Fix

Clear fragment on exception in SanitizeSafeInternal

The bug is that the error path at lines 107–109 does not clear the fragment, unlike the correct pattern at lines 90–92. The fix is to add root_element->setTextContent("") so all error paths consistently clear unsanitized content.

File: third_party/blink/renderer/core/sanitizer/sanitizer_api.cc

SanitizeSafeInternal, around line 107:

  const Sanitizer* sanitizer =
      SanitizerFromSafeOptions(options, exception_state);

  if (exception_state.HadException()) {
+   root_element->setTextContent("");
    return;
  }

This matches the existing correct pattern already used earlier:

// Lines 90-92 (SanitizeSafeInternal)
if (exception_state.HadException()) {
    root_element->setTextContent("");  // Already correctly clears fragment here
    return;
}

Impact analysis

setHTML() fails to sanitize HTML when given an invalid SanitizerConfig. The unsanitized content, including event handlers and script tags, is inserted into the live DOM and executes. This violates the spec guarantee that setHTML() always strips XSS-unsafe content.


The cause

What version of Chrome have you found the security issue in?

146.0.7680.164 stable

No, it is not related to a crash.

Choose the type of vulnerability

Cross-site scripting (XSS)

How would you like to be publicly acknowledged for your report?

Jungwoo Lee (@physicube) and Wongi Lee (@_qwerty_po)

View on issue tracker