CVE-2026-14023
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/sanitizer/sanitizer.cc |
modified |
Files Changed
third_party/blink/renderer/core/dom/shadow_root.ccthird_party/blink/renderer/core/dom/shadow_root.hthird_party/blink/renderer/core/sanitizer/sanitizer.ccthird_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-xml-document.html
Patch
From d5199b8c28a7e84222f852cafaaf7e47512714e2 Mon Sep 17 00:00:00 2001
From: Daniel Vogelheim <vogelheim@chromium.org>
Date: Mon, 01 Jun 2026 11:02:58 -0700
Subject: [PATCH] [Sanitizer] setHTML + setHTMLUnsafe use HTML fragment parser.
setHTML and setHTMLUnsafe should always use HTML syntax.
Also, conservatively always match localname + namespace, but not
prefix. The current implementation uses QualifiedName::operator==
in several places, where QualifedName::Matches (which ignores prefix)
should have been used. Sanitizer API consistently ignores the prefix.
Bug: 518063436
Change-Id: Ifca1067fa1edcc127b861543fbef42d9f62a213b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7890038
Commit-Queue: Daniel Vogelheim <vogelheim@chromium.org>
Reviewed-by: Joey Arhar <jarhar@chromium.org>
Reviewed-by: Noam Rosenthal <nrosenthal@google.com>
Cr-Commit-Position: refs/heads/main@{#1639526}
---
diff --git a/third_party/blink/renderer/core/dom/shadow_root.cc b/third_party/blink/renderer/core/dom/shadow_root.cc
index fccd9fd..7b07250b 100644
--- a/third_party/blink/renderer/core/dom/shadow_root.cc
+++ b/third_party/blink/renderer/core/dom/shadow_root.cc
@@ -160,6 +160,7 @@
SetInnerHTMLInternal(
html, FragmentParserOptions(), Sanitizer::Mode::kUnsafe,
FragmentParserConfig::ParseDeclarativeShadowRoots::kDontParse,
+ FragmentParserConfig::ForceHtml::kDontForce,
trusted_types_names::kInnerHTML, exception_state);
}
@@ -170,6 +171,7 @@
CheckHTML(html, trusted_types_names::kInnerHTML, exception_state),
FragmentParserOptions(), Sanitizer::Mode::kUnsafe,
FragmentParserConfig::ParseDeclarativeShadowRoots::kDontParse,
+ FragmentParserConfig::ForceHtml::kDontForce,
trusted_types_names::kInnerHTML, exception_state);
}
@@ -180,6 +182,7 @@
CheckHTML(html, trusted_types_names::kSetHTMLUnsafe, exception_state),
FragmentParserOptions(), Sanitizer::Mode::kUnsafe,
FragmentParserConfig::ParseDeclarativeShadowRoots::kParse,
+ FragmentParserConfig::ForceHtml::kForce,
trusted_types_names::kSetHTMLUnsafe, exception_state);
}
@@ -192,6 +195,7 @@
CheckHTML(html, trusted_types_names::kSetHTMLUnsafe, exception_state),
FragmentParserOptions(options), Sanitizer::Mode::kUnsafe,
FragmentParserConfig::ParseDeclarativeShadowRoots::kParse,
+ FragmentParserConfig::ForceHtml::kForce,
trusted_types_names::kSetHTMLUnsafe, exception_state);
}
@@ -203,6 +207,7 @@
CheckHTML(html, trusted_types_names::kSetHTMLUnsafe, exception_state),
FragmentParserOptions(options), Sanitizer::Mode::kUnsafe,
FragmentParserConfig::ParseDeclarativeShadowRoots::kParse,
+ FragmentParserConfig::ForceHtml::kForce,
trusted_types_names::kSetHTMLUnsafe, exception_state);
}
@@ -211,6 +216,7 @@
FragmentParserOptions options,
Sanitizer::Mode sanitizer_mode,
FragmentParserConfig::ParseDeclarativeShadowRoots parse_shadow_roots,
+ FragmentParserConfig::ForceHtml force_html,
const AtomicString& property_name,
ExceptionState& exception_state) {
if (exception_state.HadException()) {
@@ -222,6 +228,7 @@
{
.sanitizer_mode = sanitizer_mode,
.parse_declarative_shadows = parse_shadow_roots,
+ .force_html = force_html,
.interface_name = trusted_types_names::kShadowRoot,
.property_name = property_name,
.context_element = &host(),
@@ -238,7 +245,8 @@
SetInnerHTMLInternal(
html, FragmentParserOptions(options), Sanitizer::Mode::kSafe,
FragmentParserConfig::ParseDeclarativeShadowRoots::kParse,
- trusted_types_names::kSetHTML, exception_state);
+ FragmentParserConfig::ForceHtml::kForce, trusted_types_names::kSetHTML,
+ exception_state);
}
void ShadowRoot::RebuildLayoutTree(WhitespaceAttacher& whitespace_attacher) {
diff --git a/third_party/blink/renderer/core/dom/shadow_root.h b/third_party/blink/renderer/core/dom/shadow_root.h
index d885cdbe..3632145 100644
--- a/third_party/blink/renderer/core/dom/shadow_root.h
+++ b/third_party/blink/renderer/core/dom/shadow_root.h
@@ -232,6 +232,7 @@
FragmentParserOptions,
Sanitizer::Mode,
FragmentParserConfig::ParseDeclarativeShadowRoots,
+ FragmentParserConfig::ForceHtml,
const AtomicString& property_name,
ExceptionState&);
diff --git a/third_party/blink/renderer/core/sanitizer/sanitizer.cc b/third_party/blink/renderer/core/sanitizer/sanitizer.cc
index ecb5d61c..8d71861e 100644
--- a/third_party/blink/renderer/core/sanitizer/sanitizer.cc
+++ b/third_party/blink/renderer/core/sanitizer/sanitizer.cc
@@ -547,7 +547,7 @@
// with element. (Done by caller.)
// https://github.com/WICG/sanitizer-api/issues/365:
// If name is "html", return false.
- if (name == html_names::kHTMLTag) {
+ if (html_names::kHTMLTag.Matches(name)) {
return false;
}
// Step 4: If configuration["replaceWithChildrenElements"] contains element:
@@ -866,25 +866,25 @@
// Attributes that trigger navigation:
const QualifiedName& qname = element->TagQName();
- if (qname == html_names::kATag || qname == html_names::kAreaTag ||
- qname == html_names::kBaseTag) {
+ if (html_names::kATag.Matches(qname) || html_names::kAreaTag.Matches(qname) ||
+ html_names::kBaseTag.Matches(qname)) {
RemoveAttributeIfProtocolIsJavaScript(element, html_names::kHrefAttr);
- } else if (qname == svg_names::kATag ||
+ } else if (svg_names::kATag.Matches(qname) ||
element->namespaceURI() == mathml_names::kNamespaceURI) {
RemoveAttributeIfProtocolIsJavaScript(element, html_names::kHrefAttr);
RemoveAttributeIfProtocolIsJavaScript(element, xlink_names::kHrefAttr);
- } else if (qname == html_names::kButtonTag ||
- qname == html_names::kInputTag) {
+ } else if (html_names::kButtonTag.Matches(qname) ||
+ html_names::kInputTag.Matches(qname)) {
RemoveAttributeIfProtocolIsJavaScript(element, html_names::kFormactionAttr);
- } else if (qname == html_names::kFormTag) {
+ } else if (html_names::kFormTag.Matches(qname)) {
RemoveAttributeIfProtocolIsJavaScript(element, html_names::kActionAttr);
- } else if (qname == html_names::kIFrameTag) {
+ } else if (html_names::kIFrameTag.Matches(qname)) {
RemoveAttributeIfProtocolIsJavaScript(element, html_names::kSrcAttr);
// SVG animations of navigating attributes:
- } else if (qname == svg_names::kAnimateTag ||
- qname == svg_names::kAnimateTransformTag ||
- qname == svg_names::kSetTag) {
+ } else if (svg_names::kAnimateTag.Matches(qname) ||
+ svg_names::kAnimateTransformTag.Matches(qname) ||
+ svg_names::kSetTag.Matches(qname)) {
RemoveAttributeIfValueIsHref(element, svg_names::kAttributeNameAttr);
}
}
diff --git a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-xml-document.html b/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-xml-document.html
index 73b1deb..3b23334 100644
--- a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-xml-document.html
+++ b/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-xml-document.html
@@ -39,6 +39,14 @@
context.setHTML(testcase.data);
assert_testcase(context, testcase);
}, `Testcase #${index} with xmlDoc.setHTML("${testcase.data}")`);
+
+ test(_ => {
+ const doc = document.implementation.createDocument("application/xml", "xml");
+ doc.documentElement.appendChild(doc.createElement("div"));
+ context = doc.documentElement.firstElementChild.attachShadow({mode: "open"});
+ context.setHTML(testcase.data);
+ assert_testcase(context, testcase);
+ }, `Testcase #${index}, shadow inside xmlDoc, shadowroot.setHTML("${testcase.data}")`);
});
</script>
</body>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-xml-document.html b/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-xml-document.html
index 73b1deb..3b23334 100644
--- a/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-xml-document.html
+++ b/third_party/blink/web_tests/external/wpt/sanitizer-api/sethtml-xml-document.html
@@ -39,6 +39,14 @@
context.setHTML(testcase.data);
assert_testcase(context, testcase);
}, `Testcase #${index} with xmlDoc.setHTML("${testcase.data}")`);
+
+ test(_ => {
+ const doc = document.implementation.createDocument("application/xml", "xml");
+ doc.documentElement.appendChild(doc.createElement("div"));
+ context = doc.documentElement.firstElementChild.attachShadow({mode: "open"});
+ context.setHTML(testcase.data);
+ assert_testcase(context, testcase);
+ }, `Testcase #${index}, shadow inside xmlDoc, shadowroot.setHTML("${testcase.data}")`);
});
</script>
</body>
Original Bug Report
Sanitizer safe-mode URL stripping bypass via prefixed HTML elements in XML contexts
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The HTML Sanitizer API’s safe-mode URL stripping can potentially be bypassed by using prefixed HTML or SVG elements in XML-parsed contexts (e.g. calling ShadowRoot.setHTML on an XHTML host). This is due to a prefix-sensitive comparison check in SanitizeJavascriptNavigationAttributes that fails to match and sanitize navigation URLs for elements that carry a prefix. As a result, untrusted inputs containing javascript: URLs can bypass safe-mode sanitization and potentially execute arbitrary script upon interaction.
Affected files:
third_party/blink/renderer/core/sanitizer/sanitizer.cc
Estimated timestamp from git blame: 2025-02-21
Root Cause Analysis
In Sanitizer::SanitizeJavascriptNavigationAttributes (located in third_party/blink/renderer/core/sanitizer/sanitizer.cc), the sanitizer implements safe-mode URL stripping for navigation attributes. It performs comparison checks on element tags using QualifiedName::operator==:
const QualifiedName& qname = element->TagQName();
if (qname == html_names::kATag || qname == html_names::kAreaTag ||
qname == html_names::kBaseTag) {
RemoveAttributeIfProtocolIsJavaScript(element, html_names::kHrefAttr);
} else if (qname == svg_names::kATag || ...) { ... }
However, QualifiedName::operator== performs pointer-equality of the internal implementation (impl_), which is prefix-sensitive:
bool operator==(const QualifiedName& other) const { return impl_ == other.impl_; }
By contrast, QualifiedName::Matches is prefix-insensitive:
bool Matches(const QualifiedName& other) const {
return impl_ == other.impl_ || (LocalName() == other.LocalName() &&
NamespaceURI() == other.NamespaceURI());
}
Most other parts of the Sanitizer (such as element/attribute filtering via SanitizerNameSet and SanitizerNameMap) use Matches (prefix-insensitive). This means a prefixed element, such as <h:a xmlns:h='http://www.w3.org/1999/xhtml'>, is successfully recognized as an anchor element and kept by ActionForNode and SanitizeElement. But when it reaches SanitizeJavascriptNavigationAttributes, {prefix:"h",local:"a",ns:xhtml} == {prefix:null,local:"a",ns:xhtml} evaluates to false. As a result, the element falls through all arms of SanitizeJavascriptNavigationAttributes without having its javascript: URL stripped from the navigation attribute (e.g., href).
Suggested Potential Trigger Path
Note: These are potential steps to trigger the issue, as our automated analysis tools do not have the capability to execute code or run a live proof of concept.
- A page is served with the
application/xhtml+xmlMIME type, causing it to be parsed as XHTML. - The script on the page calls
shadowRoot.setHTML(untrusted)with an untrusted input containing a prefixed HTML/SVG element containing ajavascript:URL in a navigation attribute:<h:a xmlns:h="http://www.w3.org/1999/xhtml" href="javascript:alert(document.domain)">Click Me</h:a> - Because
force_htmlis not set by default during XHTML shadow root HTML insertion, the XML parser path is taken. - The XML parser parses
<h:a>and constructs a realHTMLAnchorElementwithTagQNamecontaining a prefix. - The sanitizer processes the node:
ActionForNodeandSanitizeElementcheck the element using prefix-insensitive maps, allowing it and itshrefattribute to persist. SanitizeJavascriptNavigationAttributesfails to match the prefix-sensitiveqname == html_names::kATagcomparison, bypassing the safety check and leaving thehref="javascript:..."value intact.- The unsanitized anchor element is inserted into the shadow root. If a user clicks it, the JavaScript payload executes.
Suggested Fix
Update Sanitizer::SanitizeJavascriptNavigationAttributes in third_party/blink/renderer/core/sanitizer/sanitizer.cc to use prefix-insensitive comparisons. This can be done by changing the == operator checks on QualifiedName to use .Matches(). For example:
const QualifiedName& qname = element->TagQName();
if (html_names::kATag.Matches(qname) || html_names::kAreaTag.Matches(qname) ||
html_names::kBaseTag.Matches(qname)) {
RemoveAttributeIfProtocolIsJavaScript(element, html_names::kHrefAttr);
} else if (svg_names::kATag.Matches(qname) || ...)
Additionally, review other places in sanitizer.cc where == or != is used on QualifiedName objects (such as CHECK_NE assertions on element tags or the name == html_names::kHTMLTag check in ReplaceElement) and convert them to use .Matches() to avoid similar prefix-sensitive bypasses of security hardening checks.
Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.