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

Overview

High
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactScript injection in SanitizerAPI
DescriptionScript injection 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.
Tracker496524586
Fix commit54315b858250 (chromium/src) +57/-1
CWECWE-94
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N
CISA KEVNot listed
CreditedJungwoo Lee (@physicube) and Wongi Lee (@_qwerty_po)
Reported2026-03-26 (per vendor advisory)
Fix landed2026-04-02 (public git)
Disclosed2026-05-12
Patch gap40 days — fix public in source before the advisory

Background

Sanitizer API
the web platform setHTML/parseHTML/parseHTMLUnsafe family that parses an HTML string and removes disallowed elements/attributes according to a SanitizerConfig before inserting it into the DOM.
`ExceptionState`
Blink’s mechanism for recording that a binding operation threw; HadException() reports whether an exception is pending for the current call.
`SanitizerFromOptions`
builds the effective Sanitizer from the caller’s options and can throw (e.g. a contradictory config such as {elements:["div"], removeElements:["div"]}).
`root_element->setTextContent("")`
empties a node’s children, used here to discard whatever was parsed when sanitization cannot complete.

Root Cause Analysis

The sanitize entry point in sanitizer_api.cc (enclosing function name not shown in the @@ -68,8 +68,8 @@ hunk) built the sanitizer via SanitizerFromOptions, and on exception_state.HadException() simply returned — but by that point content had already been parsed into root_element, so the unsanitized DOM was left in place. Upstream, the parse helpers (Document::parseHTML/Document::parseHTMLUnsafe per the regression test, and a helper in fragment_parser.cc) returned the already-built doc/fragment even though an exception was pending, so callers received the incomplete, un-sanitized result alongside the thrown exception. The invariant violated is that a throwing sanitize/parse must leave no attacker content behind and must not hand back a partial tree.

The fix makes the sanitizer entry setTextContent("") on root_element before returning on exception, and makes parseHTML, parseHTMLUnsafe and the fragment_parser.cc helper return nullptr when exception_state.HadException(). Together these ensure a failed sanitization yields empty content and a null result, confirmed by the regression test asserting doc == nullptr / div.firstChild == null with an exception set.

Key insight
The core mistake was treating a thrown exception during sanitization as a pure error signal while still leaving the parsed, unsanitized nodes attached and returning them; the fix clears the node’s content and returns nullptr on exception so no unsanitized markup survives.

Attack Path

  1. Supply a throwing sanitizer config Script calls setHTML/parseHTML(Unsafe) with a SanitizerConfig that makes SanitizerFromOptions throw (the test uses {elements:["div"], removeElements:["div"]}).
  2. Content parsed before the throw The HTML string is parsed into root_element first, so potentially dangerous markup already exists in the subtree.
  3. Pre-patch leak The sanitizer path returns on the exception without clearing root_element, and the parse helpers return the incomplete doc/fragment, so the unsanitized nodes remain reachable in the DOM.
  4. Script injection Because sanitization never ran to completion yet its output persisted, active content (e.g. event handlers/scripts the sanitizer would have stripped) can be injected and executed (script injection, as the advisory states).

Impact Assessment

An attacker who can influence the HTML string or sanitizer options passed to the Sanitizer API can cause an exception that leaves unsanitized, potentially script-bearing markup in the page’s DOM, defeating the sanitizer’s guarantee in the renderer. The advisory rates this high severity (script injection in SanitizerAPI); whether this reaches full XSS in a given embedding depends on the caller, and no in-the-wild status is stated.

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

Audit Directions

  • Throw-but-keep-output
    Audit parse/transform/sanitize routines that set an exception but still return or leave behind a partially-constructed object; the error path must also discard the partial result.
  • Security filter completion
    Where a security filter (sanitizer, policy, CSP check) can abort mid-way, verify the subject DOM/content is cleared, not left in its pre-filter state.
  • Caller trust of returned pointers
    Check callers that use a returned object without re-checking HadException(); a non-null return plus a pending exception is a recurring foot-gun.
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