Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion repository/jsrepository-master.json
Original file line number Diff line number Diff line change
Expand Up @@ -5554,7 +5554,10 @@
"summary": "DOMPurify: `CUSTOM_ELEMENT_HANDLING` bypasses `afterSanitizeElements` for allowed custom elements.",
"details": "## Summary\n\nThere is a possible hook-policy inconsistency in DOMPurify 3.4.11 involving `CUSTOM_ELEMENT_HANDLING`.\n\nWhen 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.\n\nThis 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.\n\n## Details\n\nThe issue appears to originate from the control flow in `src/purify.ts`: line 1672~1691\n\n```tsx\nconst _sanitizeDisallowedNode = function (\n currentNode: any,\n tagName: string\n ): boolean {\n /* Check if we have a custom element to handle */\n if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {\n if (\n CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&\n regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)\n ) {\n return false;\n }\n\n if (\n CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&\n CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)\n ) {\n return false;\n }\n }\n```\n\n`CUSTOM_ELEMENT_HANDLING` is parsed from user configuration at `src/purify.ts`: line 741~748\n\n```tsx\nconst customElementHandling =\n objectHasOwnProperty(cfg, 'CUSTOM_ELEMENT_HANDLING') &&\n cfg.CUSTOM_ELEMENT_HANDLING &&\n typeof cfg.CUSTOM_ELEMENT_HANDLING === 'object'\n ? clone(cfg.CUSTOM_ELEMENT_HANDLING)\n : create(null);\n\n CUSTOM_ELEMENT_HANDLING = create(null);\n```\n\nIn particular, `tagNameCheck`, `attributeNameCheck`, and `allowCustomizedBuiltInElements` are copied into the internal `CUSTOM_ELEMENT_HANDLING` object there.\n\nDuring element sanitization, `_sanitizeElements()` checks whether a node is forbidden or not allowlisted at `src/purify.ts`: line 1805~1814\n\n```tsx\n/* Remove element if anything forbids its presence */\n if (\n FORBID_TAGS[tagName] ||\n (!(\n EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function &&\n EXTRA_ELEMENT_HANDLING.tagCheck(tagName)\n ) &&\n !ALLOWED_TAGS[tagName])\n ) {\n return _sanitizeDisallowedNode(currentNode, tagName);\n }\n```\n\nIf so, it immediately delegates to `_sanitizeDisallowedNode(currentNode, tagName)` and returns its boolean result.\n\nInside `_sanitizeDisallowedNode()`, the custom-element-specific allow path is implemented at `src/purify.ts`: line 1672~1692\n\n```tsx\nconst _sanitizeDisallowedNode = function (\n currentNode: any,\n tagName: string\n ): boolean {\n /* Check if we have a custom element to handle */\n if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {\n if (\n CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&\n regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)\n ) {\n return false;\n }\n\n if (\n CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&\n CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)\n ) {\n return false;\n }\n }\n```\n\nIf 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”.\n\nThat 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:\n\n- the namespace validation at `src/purify.ts`: line 1816~1826\n\n```tsx\n* Check whether element has a valid namespace.\n Realm-safe check (GHSA-hpcv-96wg-7vj8): use the cached Node.prototype\n nodeType getter rather than `instanceof Element`, which is realm-\n bound and short-circuits to false for any node minted in a different\n realm — letting a foreign-realm element with a forbidden namespace\n slip past the namespace check entirely. */\n const nt = getNodeType ? getNodeType(currentNode) : currentNode.nodeType;\n if (nt === NODE_TYPE.element && !_checkValidNamespace(currentNode)) {\n _forceRemove(currentNode);\n return true;\n }\n```\n\n- the fallback-tag mXSS check at `src/purify.ts`: line 1828~1837\n\n```tsx\n/* Make sure that older browsers don't get fallback-tag mXSS */\n if (\n (tagName === 'noscript' ||\n tagName === 'noembed' ||\n tagName === 'noframes') &&\n regExpTest(EXPRESSIONS.FALLBACK_TAG_CLOSE, currentNode.innerHTML)\n ) {\n _forceRemove(currentNode);\n return true;\n }\n```\n\n- most importantly for this report, the `afterSanitizeElements` hook dispatch at `src/purify.ts`: line 1850~1851.\n\n```tsx\n /* Execute a hook if present */\n _executeHooks(hooks.afterSanitizeElements, currentNode, null);\n```\n\nIn 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`.\n\nIn 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.\n\n## PoC\n\nReproduced on DOMPurify 3.4.11.\n\n### Steps\n\n1. Save the following HTML to a file, for example `poc.html`.\n2. Open it in a browser.\n3. Observe that the `div` control loses `data-bio`, while the allowed custom element keeps it.\n4. Observe that after `connectedCallback()` runs, the candidate payload is reinserted into the DOM and executes through the custom element’s own sink.\n\n### HTML PoC\n\n```html\n<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"UTF-8\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.11/purify.min.js\"></script>\n</head>\n<body>\n<pre id=\"result\"></pre>\n\n<script>\nwindow.__controlFired = false;\nwindow.__candidateFired = false;\n\ncustomElements.define(\"x-bio\", class extends HTMLElement {\n connectedCallback() {\n const bio = this.getAttribute(\"data-bio\");\n if (bio) this.innerHTML = bio;\n }\n});\n\nDOMPurify.addHook(\"afterSanitizeElements\", node => {\n if (node.hasAttribute && node.hasAttribute(\"data-bio\")) {\n node.removeAttribute(\"data-bio\");\n }\n});\n\nconst config = {\n CUSTOM_ELEMENT_HANDLING: {\n tagNameCheck: /^x-/\n }\n};\n\nconst controlInput =\n '<div data-bio=\"&lt;img src=x onerror=window.__controlFired=true&gt;\"></div>';\n\nconst candidateInput =\n '<x-bio data-bio=\"&lt;img src=x onerror=window.__candidateFired=true&gt;\"></x-bio>';\n\nconst cleanControl = DOMPurify.sanitize(controlInput, config);\nconst cleanCandidate = DOMPurify.sanitize(candidateInput, config);\n\nconst container = document.createElement(\"div\");\ncontainer.innerHTML = cleanCandidate;\ndocument.body.appendChild(container);\n\nsetTimeout(() => {\n document.getElementById(\"result\").textContent =\n \"This is not direct DOMPurify XSS.\\n\" +\n \"The payload becomes executable only after x-bio writes data-bio into innerHTML.\\n\\n\" +\n \"control: \" + cleanControl + \"\\n\" +\n \"candidate: \" + cleanCandidate + \"\\n\" +\n \"after connectedCallback: \" + container.innerHTML + \"\\n\" +\n \"control fired: \" + window.__controlFired + \"\\n\" +\n \"candidate fired: \" + window.__candidateFired;\n}, 100);\n</script>\n</body>\n</html>\n```\n\n### Expected result\n\n```\ncontrol: <div></div>\ncandidate: <x-bio data-bio=\"<img src=x onerror=window.__candidateFired=true>\"></x-bio>\nafter connectedCallback: <x-bio data-bio=\"...\"><img src=\"x\" onerror=\"window.__candidateFired=true\"></x-bio>\ncontrol fired: false\ncandidate fired: true\n```\n\nThis is output of HTML PoC.\n\n<img width=\"1917\" height=\"961\" alt=\"poc\" src=\"https://github.com/user-attachments/assets/80e22989-5779-42f8-8ffb-106e9a4c2b10\" />\n\n\n## Impact\n\nThis does not appear to affect DOMPurify’s default configuration as a direct sanitizer bypass.\n\nThe impact is limited to applications that:\n\n- enable `CUSTOM_ELEMENT_HANDLING`,\n- rely on `afterSanitizeElements` as a security policy layer,\n- expect that hook to apply uniformly to all surviving elements,\n- and have allowed custom elements that later re-inject preserved attribute values into `innerHTML` or another HTML sink.\n\nIn 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.\n\nPossible fixes or mitigations might include\n\n- ensuring that allowed custom elements also consistently pass through `afterSanitizeElements`\n- documenting clearly that elements preserved via `CUSTOM_ELEMENT_HANDLING` may not participate in the same post-element hook flow as normal allowlisted elements.",
"identifiers": {
"githubID": "GHSA-c2j3-45gr-mqc4"
"githubID": "GHSA-c2j3-45gr-mqc4",
"CVE": [
"CVE-2026-66010"
]
},
"severity": "low",
"cwe": [
Expand Down
5 changes: 4 additions & 1 deletion repository/jsrepository-v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -5919,7 +5919,10 @@
],
"identifiers": {
"summary": "DOMPurify: `CUSTOM_ELEMENT_HANDLING` bypasses `afterSanitizeElements` for allowed custom elements.",
"githubID": "GHSA-c2j3-45gr-mqc4"
"githubID": "GHSA-c2j3-45gr-mqc4",
"CVE": [
"CVE-2026-66010"
]
},
"info": [
"https://github.com/cure53/DOMPurify/security/advisories/GHSA-c2j3-45gr-mqc4",
Expand Down
5 changes: 4 additions & 1 deletion repository/jsrepository-v3.json
Original file line number Diff line number Diff line change
Expand Up @@ -6016,7 +6016,10 @@
],
"identifiers": {
"summary": "DOMPurify: `CUSTOM_ELEMENT_HANDLING` bypasses `afterSanitizeElements` for allowed custom elements.",
"githubID": "GHSA-c2j3-45gr-mqc4"
"githubID": "GHSA-c2j3-45gr-mqc4",
"CVE": [
"CVE-2026-66010"
]
},
"info": [
"https://github.com/cure53/DOMPurify/security/advisories/GHSA-c2j3-45gr-mqc4",
Expand Down
5 changes: 4 additions & 1 deletion repository/jsrepository-v4.json
Original file line number Diff line number Diff line change
Expand Up @@ -6015,7 +6015,10 @@
],
"identifiers": {
"summary": "DOMPurify: `CUSTOM_ELEMENT_HANDLING` bypasses `afterSanitizeElements` for allowed custom elements.",
"githubID": "GHSA-c2j3-45gr-mqc4"
"githubID": "GHSA-c2j3-45gr-mqc4",
"CVE": [
"CVE-2026-66010"
]
},
"info": [
"https://github.com/cure53/DOMPurify/security/advisories/GHSA-c2j3-45gr-mqc4",
Expand Down
5 changes: 4 additions & 1 deletion repository/jsrepository-v5-combined.json
Original file line number Diff line number Diff line change
Expand Up @@ -6022,7 +6022,10 @@
],
"identifiers": {
"summary": "DOMPurify: `CUSTOM_ELEMENT_HANDLING` bypasses `afterSanitizeElements` for allowed custom elements.",
"githubID": "GHSA-c2j3-45gr-mqc4"
"githubID": "GHSA-c2j3-45gr-mqc4",
"CVE": [
"CVE-2026-66010"
]
},
"info": [
"https://github.com/cure53/DOMPurify/security/advisories/GHSA-c2j3-45gr-mqc4",
Expand Down
5 changes: 4 additions & 1 deletion repository/jsrepository-v5.json
Original file line number Diff line number Diff line change
Expand Up @@ -6021,7 +6021,10 @@
],
"identifiers": {
"summary": "DOMPurify: `CUSTOM_ELEMENT_HANDLING` bypasses `afterSanitizeElements` for allowed custom elements.",
"githubID": "GHSA-c2j3-45gr-mqc4"
"githubID": "GHSA-c2j3-45gr-mqc4",
"CVE": [
"CVE-2026-66010"
]
},
"info": [
"https://github.com/cure53/DOMPurify/security/advisories/GHSA-c2j3-45gr-mqc4",
Expand Down
Loading
Loading