{"id":"CVE-2026-113657","kind":"vulnerability","aliases":["CVE-2026-113657","GHSA-374f-7chj-9948"],"related":[],"title":"ApostropheCMS sanitize-html: SMIL animation namespace-prefix bypass allows javascript: URI injection (XSS)","description":"## Summary\n\nsanitize-html 2.17.7 introduced `animatesUrlAttribute()` to block SVG animation elements (`animate`, `animateColor`, `animateMotion`, `animateTransform`, `set`) when their `attributeName` targets a URL sink like `href`. However, this check is bypassed when the animation element uses a namespace prefix (e.g., `svg:animate` instead of `animate`).\n\n## Root Cause\n\nThe `animatesUrlAttribute` function (index.js, line ~832) checks `svgAnimationTags.indexOf(name.toLowerCase())`, where `svgAnimationTags` contains bare local names: `[\"animate\", \"animatecolor\", \"animatemotion\", \"animatetransform\", \"set\"]`. htmlparser2 passes namespace-prefixed tag names verbatim, so `svg:animate` does not match `animate` in the array, causing the function to return `false` and skip the SMIL XSS protection.\n\nIronically, the function already handles namespace prefixes correctly for the `attributeName` target value (line ~843): `const localName = target.slice(target.lastIndexOf(\":\") + 1)` — but this same stripping was not applied to the tag name itself.\n\n## Proof of Concept\n\n```javascript\nconst sanitizeHtml = require(\"sanitize-html\");\n\nconst config = {\n  allowedTags: false,\n  allowedAttributes: false,\n  allowedSchemes: [\"http\", \"https\"]\n};\n\n// Standard animate — BLOCKED by 2.17.7 fix\nconst blocked = sanitizeHtml(\n  `<svg><a href=\"#safe\"><animate attributeName=\"href\" values=\"#safe;javascript:alert(1)\" dur=\".01s\" fill=\"freeze\"/><text>Click</text></a></svg>`,\n  config\n);\nconsole.log(\"Standard animate:\", blocked.includes(\"javascript:\") ? \"BYPASS\" : \"BLOCKED\");\n// Output: BLOCKED\n\n// Namespace-prefixed animate — BYPASSES the fix\nconst bypassed = sanitizeHtml(\n  `<svg><a href=\"#safe\"><svg:animate attributeName=\"href\" values=\"#safe;javascript:alert(document.domain)\" dur=\".01s\" fill=\"freeze\"/><text>Click</text></a></svg>`,\n  config\n);\nconsole.log(\"svg:animate:\", bypassed.includes(\"javascript:\") ? \"BYPASS\" : \"BLOCKED\");\n// Output: BYPASS\n```\n\nAll 5 SMIL animation elements are bypassable with any namespace prefix (`svg:`, `xlink:`, `xml:`, uppercase variants). Tested and confirmed on sanitize-html 2.17.7 from the apostrophe monorepo.\n\n## Impact\n\nCross-site scripting (XSS) via SMIL animation retargeting. When an application uses `allowedTags: false` (common in rich-text editors that want to preserve SVG), an attacker can inject `<svg:animate attributeName=\"href\" values=\"#safe;javascript:...\" .../>` to override the `href` attribute of a parent `<a>` element after the page loads, causing the link to execute JavaScript when clicked.\n\n## Suggested Fix\n\nStrip the namespace prefix from the tag name before the lookup, matching the pattern already used for the attribute name target:\n\n```javascript\n// Before (vulnerable):\nif (svgAnimationTags.indexOf(name.toLowerCase()) === -1) {\n\n// After (fixed):\nconst localTagName = name.toLowerCase().slice(name.lastIndexOf(\":\") + 1);\nif (svgAnimationTags.indexOf(localTagName) === -1) {\n```\n\n## Affected Versions\n\nsanitize-html >= 2.17.7 (where the SMIL animation protection was introduced). Earlier versions lack the SMIL protection entirely.\n\n## Credit\n\nKai Aizen (SnailSploit) — Adversarial AI & Security Research","summary":"sanitize-html versions 2.17.7 up to 2.18.0 fail to block SVG animation tags written with a namespace prefix such as svg:animate, so the protection against javascript: URLs in animated href attributes is skipped. An attacker who can get markup past the sanitizer on a site using allowedTags: false can inject a link that runs JavaScript when clicked (XSS).","severity":"low","cvss":3.700000047683716,"epss":0.0031300000846385956,"exploited":false,"exploited_at":null,"products":[{"name":"sanitize-html","fixed":["2.18.0"],"affected":">= 2.17.7, < 2.18.0","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["apostrophecms","cwe-79","node.js","npm","package","rich-text-editors","sanitize-html"],"not_affected_if":["allowedtags-restricted-without-svg","version-2.18.0-or-later","version-below-2.17.7"],"how_to_check":"Run npm ls sanitize-html and check whether the resolved version is >= 2.17.7 and < 2.18.0.\nSearch the codebase for sanitizeHtml or sanitize-html calls that use allowedTags: false or that allow svg.","fix":"Upgrade sanitize-html to 2.18.0 or later.\nUntil then, keep svg and animate-type tags out of allowedTags or strip them before sanitizing.","status":"open","published_at":"2026-10-20T17:17:36+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","triaged_at":"2026-10-23T20:03:30+00:00","updated_at":"2026-10-23T21:57:20+00:00","category":"package","confidence":0.85,"triage":{"at":"2026-10-23T20:03:30Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade sanitize-html to 2.18.0 or later.\nUntil then, keep svg and animate-type tags out of allowedTags or strip them before sanitizing.","summary":"sanitize-html versions 2.17.7 up to 2.18.0 fail to block SVG animation tags written with a namespace prefix such as svg:animate, so the protection against javascript: URLs in animated href attributes is skipped. An attacker who can get markup past the sanitizer on a site using allowedTags: false can inject a link that runs JavaScript when clicked (XSS).","category":"package","who_cares":["npm","sanitize-html","apostrophecms","rich-text-editors","node.js"],"confidence":0.85,"analyst_note":"","how_to_check":"Run npm ls sanitize-html and check whether the resolved version is >= 2.17.7 and < 2.18.0.\nSearch the codebase for sanitizeHtml or sanitize-html calls that use allowedTags: false or that allow svg.\nunknown","needs_analyst":false,"not_affected_if":["version-below-2.17.7","version-2.18.0-or-later","allowedTags-restricted-without-svg"],"severity_if_unknown":"low"},"cost_usd":0.000541,"input_tokens":3302,"output_tokens":421,"severity_before":"low"},"analyst_status":null,"analyst_note":null,"search":"'-1':401C,413C '-113657':18A,26A '-2026':17A,25A '-79':32B '1':219C,409C '2.17.7':47B,107C,255C,315C,419C '2.18.0':50B '374f':21A '5':293C '7chj':22A '832':155C '843':214C '9948':23A 'adversari':438C 'affect':414C 'ai':439C 'aizen':436C 'allow':11A 'allowedattribut':246C 'allowedschem':248C 'allowedtag':91B,244C,334C 'alreadi':203C,388C 'anim':6A,55B,65B,73B,113C,115C,136C,144C,147C,165C,180C,184C,252C,264C,274C,285C,295C,328C,423C 'animatecolor':116C,166C 'animatemot':117C,167C 'animatesurlattribut':109C,151C 'animatetransform':118C,168C 'apostroph':318C 'apostrophecm':1A,30B 'appli':226C 'applic':332C 'array':187C 'attack':79B,348C 'attribut':75B,355C,392C 'attributenam':122C,210C 'bare':162C 'block':53B,111C,253C,258C,268C,270C,289C 'blocked.includes':265C 'bypass':10A,133C,267C,275C,279C,288C,291C,298C 'bypassed.includes':286C 'caus':149C,188C,364C 'check':131C,156C 'click':101B,260C,281C,371C 'common':336C 'concept':234C 'config':243C,261C,282C 'confirm':310C 'console.log':262C,283C 'const':215C,236C,242C,257C,278C,404C 'contain':161C 'correct':207C 'credit':434C 'cross':322C 'cross-sit':321C 'cve':16A,24A 'cwe':31B 'e.g':142C 'earlier':427C 'editor':39B,341C 'element':114C,137C,296C,359C 'entir':433C 'execut':368C 'fail':51B 'fals':92B,193C,245C,247C,335C 'fix':256C,277C,373C,403C 'function':152C,190C,202C 'get':82B 'ghsa':20A 'ghsa-374f-7chj':19A 'handl':204C 'howev':129C 'href':74B,128C,354C 'html':4A,29A,42B,45B,106C,241C,314C,418C 'htmlparser2':170C 'http':249C 'https':250C 'impact':320C 'index.js':153C 'inject':14A,94B,350C 'instead':145C 'introduc':108C,426C 'iron':200C 'javascript':12A,70B,99B,235C,266C,287C,369C,395C 'kai':435C 'lack':429C 'like':127C 'line':154C,213C 'link':96B,366C 'load':363C 'local':163C 'localnam':216C 'localtagnam':405C,412C 'lookup':384C 'markup':83B 'match':183C,385C 'monorepo':319C 'name':164C,176C,230C,381C,393C 'name.lastindexof':408C 'name.tolowercase':158C,400C,406C 'namespac':8A,60B,140C,173C,205C,272C,301C,376C 'namespace-prefix':7A,172C,271C 'node.js':33B 'npm':34B 'output':269C,290C 'overrid':352C 'packag':35B 'page':362C 'parent':358C 'pass':171C 'past':84B 'pattern':387C 'prefix':9A,61B,141C,174C,206C,273C,302C,377C 'preserv':345C 'proof':232C 'protect':68B,199C,424C,432C 'requir':238C 'research':441C 'retarget':329C 'return':192C 'rich':37B,339C 'rich-text':338C 'rich-text-editor':36B 'root':148C 'run':98B 'sanit':3A,41B,44B,86B,105C,240C,313C,417C 'sanitize':28A 'sanitize-html':2A,27A,40B,43B,104C,239C,312C,416C 'sanitizehtml':237C,259C,280C 'script':324C 'secur':440C 'set':119C,169C 'sink':126C 'site':89B,323C 'skip':77B,195C 'slice':407C 'smil':5A,197C,294C,327C,422C,431C 'snailsploit':437C 'standard':251C,263C 'strip':223C,374C 'suggest':372C 'summari':103C 'svg':54B,64B,112C,143C,179C,284C,303C,346C 'svganimationtag':160C 'svganimationtags.indexof':157C,399C,411C 'tag':56B,175C,229C,380C 'target':123C,211C,394C 'target.lastindexof':218C 'target.slice':217C 'test':308C 'text':38B,340C 'uppercas':306C 'uri':13A 'url':71B,125C 'use':90B,138C,333C,389C 'valu':212C 'variant':307C 'verbatim':177C 'version':46B,415C,428C 'via':326C 'vulner':397C 'want':343C 'written':57B 'xlink':304C 'xml':305C 'xss':15A,102B,198C,325C","nicknames":[],"mitigations":[],"who_affected":null,"tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-20T17:17:36+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-374f-7chj-9948","url":"https://github.com/advisories/GHSA-374f-7chj-9948","severity":"low","published_at":"2026-10-20T18:21:19+00:00","modified_at":"2026-10-20T18:21:20+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","fetched_at":"2026-10-23T19:11:32+00:00","note":null},{"source":"nvd","external_id":"CVE-2026-113657","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-113657","severity":null,"published_at":"2026-10-20T17:17:36+00:00","modified_at":"2026-10-20T19:53:10+00:00","first_seen_at":"2026-10-23T21:57:20+00:00","fetched_at":"2026-10-23T22:28:58+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"}