CVE-2026-117967
Overview
Background
- `nsTreeSanitizer`
- Firefox’s HTML/XML sanitizer (
dom/base/nsTreeSanitizer.cpp) used bynsIParserUtils::sanitize,Sanitizer/setHTML, and fragment parsing to strip disallowed elements, attributes, and scripting constructs from untrusted markup. - Customized built-in elements / the `is` attribute
- HTML lets an author upgrade a built-in element to a customized built-in via
is="..."(e.g.<style is="custom-style">). The upgrade is recorded asCustomElementDataattached to the element, retrievable withGetCustomElementData()/HasCustomElementData(). - `CustomElementData`
- Per-element state tracking the custom-element registration (including the
isname viaGetIs()); if left on a sanitized element it can later cause the element to be treated as a customized built-in and run author-defined reactions. - `SanitizeAttributes` vs `SanitizeChildren`
SanitizeChildren(nsINode*)walks the subtree and decides which elements survive;SanitizeAttributes(Element*, AllowedAttributes)strips disallowed attributes on a kept element. Not every kept element flows through the same point inSanitizeChildren’s per-namespace branch.- Mitigation bypass
- Advisory class: the sanitizer is a security mitigation, and a construct surviving it that should have been neutralized is a bypass.
Root Cause Analysis
The patch relocates the clearing of CustomElementData. Previously, SanitizeChildren(nsINode* aRoot) cleared custom-element data inline, immediately before resolving the per-namespace AllowedAttributes for an element in the XHTML/MathML/SVG branch:
if (elt->HasCustomElementData()) {
MOZ_ASSERT(elt->GetCustomElementData()->GetIs(elt), ...);
elt->ClearCustomElementData();
}
The defect is that this clearing lived in only one code path of the sanitizer, so elements that reach attribute sanitization through other routes — or elements like <style> that are kept and attribute-sanitized but not neutralized along that exact branch — could retain their CustomElementData from an is="..." attribute even after the sanitizer removed the is attribute itself. An element left carrying custom-element data is still treated as a customized built-in, re-enabling customized-built-in behavior on sanitized output and defeating the sanitizer. The patch moves the identical clearing block into SanitizeAttributes at its very start, so that every element whose attributes are sanitized has its custom-element data cleared, regardless of which caller reached it. The accompanying parser test adds <style is="custom-style"> with SanitizerAllowStyle, confirming the <style> upgrade is stripped to a plain <style>. The advisory’s generic “mitigation bypass in DOM: Security” is consistent with the diff: this is a sanitizer bypass (PolicyBypass), not a memory-safety bug.
is attribute was cleared in only one branch of the tree walk, so elements attribute-sanitized through another path kept their customized-built-in state and survived the sanitizer as live custom elements; the fix moves the clear into SanitizeAttributes, the choke point every kept element passes through.Attack Path
- Attacker supplies markup
Attacker controls an HTML string that will be run through
nsTreeSanitizer(e.g. via a sanitization API, pasted content, or an embedding that sanitizes before insertion). - Embed a customized built-in
Include an element such as
<style is="custom-style">(or another element reaching attribute sanitization on a path that did not clear custom-element data). - Bypass the clear
The
isattribute is stripped, but the element retains itsCustomElementDatabecause the pre-fix clear inSanitizeChildrendid not cover this element. - Sanitized output stays a custom element The surviving element is still a customized built-in in the output document.
- Primitive achieved
Custom-element reactions/registration tied to the
isname apply to content that was supposed to be inert sanitized markup, bypassing the sanitizer mitigation (any script execution depends on a matching registration, inferred).
Impact Assessment
nsTreeSanitizer is a candidate. The attacker gains a sanitizer bypass — markup that should be neutralized retains customized-built-in state. Whether this escalates to script execution depends on the presence of a matching custom-element definition and the sink that consumes the sanitized output (inferred, not shown by the diff); the diff itself only proves the custom-element data was not cleared. Mozilla rates it moderate. The primitive is best described as a mitigation/sanitizer bypass rather than direct memory corruption.Changed Functions
| Function | Change | Notes |
|---|---|---|
fordom/base/nsTreeSanitizer.cpp |
modified | |
ifdom/base/nsTreeSanitizer.cpp |
modified |
Files Changed
dom/base/nsTreeSanitizer.cppparser/xml/test/unit/results.js
Audit Directions
- Per-branch vs choke-point security actionsLook for other sanitizer actions placed inside one namespace branch of
SanitizeChildrenthat should apply to every kept element; moving security-critical clears toSanitizeAttributes-style choke points is the pattern here. - CustomElementData persistenceAudit other DOM mutation/cloning/adoption paths (fragment parsing,
importNode, template instantiation) that keep elements but might not clear staleCustomElementDatawhen theisattribute is removed. - Element kept but not fully neutralizedReview elements that
nsTreeSanitizerkeeps (style, templates, SVG/MathML specials) for other residual customized-built-in or scripting state surviving sanitization.
Patch
diff --git a/dom/base/nsTreeSanitizer.cpp b/dom/base/nsTreeSanitizer.cpp
index 1d4bedcb65f..be4d2fbc184 100644
--- a/dom/base/nsTreeSanitizer.cpp
+++ b/dom/base/nsTreeSanitizer.cpp
@@ -1183,6 +1183,13 @@ static bool UTF16StringStartsWith(const char16_t* aStr, uint32_t aLength,
void nsTreeSanitizer::SanitizeAttributes(mozilla::dom::Element* aElement,
AllowedAttributes aAllowed) {
+ // Clear the custom element data introduced by the "is" attribute.
+ if (aElement->HasCustomElementData()) {
+ MOZ_ASSERT(aElement->GetCustomElementData()->GetIs(aElement),
+ "CustomElementData without an |is| attribute?");
+ aElement->ClearCustomElementData();
+ }
+
int32_t ac = (int)aElement->GetAttrCount();
for (int32_t i = ac - 1; i >= 0; --i) {
@@ -1451,11 +1458,6 @@ void nsTreeSanitizer::SanitizeChildren(nsINode* aRoot) {
NS_ASSERTION(ns == kNameSpaceID_XHTML || ns == kNameSpaceID_SVG ||
ns == kNameSpaceID_MathML,
"Should have only HTML, MathML or SVG here!");
- if (elt->HasCustomElementData()) {
- MOZ_ASSERT(elt->GetCustomElementData()->GetIs(elt),
- "CustomElementData without an |is| attribute?");
- elt->ClearCustomElementData();
- }
AllowedAttributes allowed;
if (ns == kNameSpaceID_XHTML) {
allowed.mNames = sAttributesHTML;
diff --git a/parser/xml/test/unit/results.js b/parser/xml/test/unit/results.js
index b71adfb6aff..b71c7925370 100644
--- a/parser/xml/test/unit/results.js
+++ b/parser/xml/test/unit/results.js
@@ -945,4 +945,10 @@ var vectors = [
data: `<div is="custom-div">foo</div>`,
sanitized: "<html><head></head><body><div>foo</div></body></html>",
},
+ {
+ data: `<body><style is="custom-style">* { color: red; }</style></body>`,
+ flags: Ci.nsIParserUtils.SanitizerAllowStyle,
+ sanitized:
+ "<html><head></head><body><style>* { color: red; }</style></body></html>",
+ },
];
Regression Test / PoC
diff --git a/parser/xml/test/unit/results.js b/parser/xml/test/unit/results.js
index b71adfb6aff..b71c7925370 100644
--- a/parser/xml/test/unit/results.js
+++ b/parser/xml/test/unit/results.js
@@ -945,4 +945,10 @@ var vectors = [
data: `<div is="custom-div">foo</div>`,
sanitized: "<html><head></head><body><div>foo</div></body></html>",
},
+ {
+ data: `<body><style is="custom-style">* { color: red; }</style></body>`,
+ flags: Ci.nsIParserUtils.SanitizerAllowStyle,
+ sanitized:
+ "<html><head></head><body><style>* { color: red; }</style></body></html>",
+ },
];