{"id":"CVE-2026-113654","kind":"vulnerability","aliases":["CVE-2026-113654","GHSA-x3q4-9hxx-gx8m"],"related":[],"title":"ApostropheCMS sanitize-html: nonTextTags discard region ends early on an implied close, letting <noscript> content render","description":"## Summary\n\n`sanitize-html` discards the *content* of every element in `nonTextTags`. It delimits that region\nusing htmlparser2's close events, and ignores the `isImplied` flag htmlparser2 passes it. Closing a\nstill-open **ancestor** from inside a `<noscript>` makes htmlparser2 unwind its stack past the\n`noscript`; the synthetic implied close it emits on the way ends the discard region early, and\neverything after it is treated as ordinary body content.\n\nA browser with scripting enabled parses `<noscript>` content as **raw text**, so the same markup\nrenders as inert text there. The sanitizer therefore emits elements the page itself would not have.\n\n## Reproduction\n\n```js\nconst sanitizeHtml = require(\"sanitize-html\");\n\nsanitizeHtml('<div><noscript></div><img src=\"http://evil.test/t.gif\"></noscript>', {\n  allowedTags: [\"div\", \"img\"],\n  allowedAttributes: { img: [\"src\"] },\n  nonTextTags: [\"script\", \"style\", \"textarea\", \"option\", \"noscript\"],\n});\n// => '<div></div><img src=\"http://evil.test/t.gif\" />'\n```\n\nChrome, given the same input, renders **no** `<img>` — the whole payload is raw text inside the\nunclosed `<noscript>`. Verified with Playwright, `srcdoc` into an iframe so the scripting flag is on.\n\nA `<![CDATA[` variant hides the opening tag from the parser entirely and has the same effect:\n\n```\n<p>Hi</p><![CDATA[ x > <noscript>]]><a href=\"…\">Reset your password</a><img src=\"…beacon.gif\"></noscript>\n```\n\nhtmlparser2 runs a CDATA token to `]]>`; Chrome ends a bogus comment at the first `>`, so the\n`<noscript>` after it is real to the browser and invisible to the parser.\n\n## Where the behaviour is\n\n`index.js`:\n\n- `:154` — `nonTextTagsArray` (the default list, `['script','style','textarea','option','xmp']`).\n- `:638` — `onclosetag: function (name, isImplied)` — receives the flag.\n- `:644` — `skipTextDepth--` — decrements without consulting `isImplied`.\n\n## Suggested fix\n\nDo not end a discard region on an implied close. htmlparser2 already distinguishes them; the flag\nis passed and discarded. Something close to:\n\n```js\nonclosetag: function (name, isImplied) {\n  if (skipText) {\n    if (isImplied) return;   // an implied close must not end the region\n    skipTextDepth--;\n    …\n```","summary":"sanitize-html discards element content for tags listed in nonTextTags, but a crafted <noscript> with an implied close ends that discard region early, so markup after it is passed through. An attacker can get elements such as img beacons or links into sanitized output that a browser with scripting enabled would show as inert text, which enables markup injection (CWE-79).","severity":"medium","cvss":5.3,"epss":0.003490000031888485,"exploited":false,"exploited_at":null,"products":[{"name":"sanitize-html","fixed":["2.18.0"],"affected":"< 2.18.0","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["cwe-79","node","npm","package","sanitize-html","user-generated-html","web-apps"],"not_affected_if":["no-user-html-input","output-not-rendered-as-html","version-2.18.0-or-later"],"how_to_check":"Run npm ls sanitize-html and check whether the version is below 2.18.0.\nTest sanitizeHtml('<div><noscript></div><img src=\"http://evil.test/t.gif\"></noscript>', {allowedTags:[\"div\",\"img\"],allowedAttributes:{img:[\"src\"]},nonTextTags:[\"script\",\"style\",\"textarea\",\"option\",\"noscript\"]}) and see whether an img tag is returned.","fix":"Upgrade sanitize-html to 2.18.0 or later.","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:13:37+00:00","updated_at":"2026-10-23T21:57:20+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-23T20:13:37Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade sanitize-html to 2.18.0 or later.\nUnknown whether a workaround exists short of upgrading.","summary":"sanitize-html discards element content for tags listed in nonTextTags, but a crafted <noscript> with an implied close ends that discard region early, so markup after it is passed through. An attacker can get elements such as img beacons or links into sanitized output that a browser with scripting enabled would show as inert text, which enables markup injection (CWE-79).","category":"package","who_cares":["npm","sanitize-html","node","web-apps","user-generated-html"],"confidence":0.8,"analyst_note":"","how_to_check":"Run npm ls sanitize-html and check whether the version is below 2.18.0.\nTest sanitizeHtml('<div><noscript></div><img src=\"http://evil.test/t.gif\"></noscript>', {allowedTags:[\"div\",\"img\"],allowedAttributes:{img:[\"src\"]},nonTextTags:[\"script\",\"style\",\"textarea\",\"option\",\"noscript\"]}) and see whether an img tag is returned.","needs_analyst":false,"not_affected_if":["version-2.18.0-or-later","no-user-html-input","output-not-rendered-as-html"],"severity_if_unknown":"medium"},"cost_usd":0.000514,"input_tokens":2850,"output_tokens":457,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-113654':19A,27A '-2026':18A,26A '-79':32B,106B '154':312C '638':322C '644':330C '9hxx':23A 'allowedattribut':219C 'allowedtag':216C 'alreadi':349C 'ancestor':141C 'apostrophecm':1A 'app':45B 'attack':77B 'beacon':84B 'behaviour':309C 'bodi':175C 'bogus':288C 'browser':92B,178C,301C 'cdata':258C,274C,282C 'chrome':228C,285C 'close':13A,63B,126C,136C,156C,347C,359C,373C 'comment':289C 'const':209C 'consult':334C 'content':15A,51B,113C,176C,183C 'craft':59B 'cve':17A,25A 'cwe':31B,105B 'decrement':332C 'default':315C 'delimit':120C 'discard':6A,49B,66B,111C,164C,342C,357C 'distinguish':350C 'div':217C 'earli':9A,68B,166C 'effect':272C 'element':50B,80B,116C,200C 'emit':158C,199C 'enabl':95B,102B,181C 'end':8A,64B,162C,286C,340C,376C 'entir':267C 'event':127C 'everi':115C 'everyth':168C 'first':292C 'fix':337C 'flag':132C,254C,329C,353C 'function':324C,363C 'generat':41B 'get':79B 'ghsa':21A 'ghsa-x3q4-9hxx-gx8m':20A 'given':229C 'gx8m':24A 'hi':273C 'hide':260C 'html':4A,30A,38B,42B,48B,110C,214C 'htmlparser2':124C,133C,146C,279C,348C 'ifram':250C 'ignor':129C 'img':83B,218C,220C 'impli':12A,62B,155C,346C,372C 'index.js':311C 'inert':99B,193C 'inject':104B 'input':232C 'insid':143C,241C 'invis':303C 'isimpli':131C,326C,335C,365C,369C 'js':208C,361C 'let':14A 'link':86B 'list':54B,316C 'make':145C 'markup':70B,103B,190C 'must':374C 'name':325C,364C 'node':33B 'nontexttag':5A,56B,118C,222C 'nontexttagsarray':313C 'noscript':152C,227C 'npm':34B 'onclosetag':323C,362C 'open':140C,262C 'option':226C,320C 'ordinari':174C 'output':89B 'packag':35B 'page':202C 'pars':182C 'parser':266C,306C 'pass':74B,134C,355C 'password':278C 'past':150C 'payload':237C 'playwright':246C 'raw':185C,239C 'real':298C 'receiv':327C 'region':7A,67B,122C,165C,343C,378C 'render':16A,191C,233C 'reproduct':207C 'requir':211C 'reset':276C 'return':370C 'run':280C 'sanit':3A,37B,47B,88B,109C,197C,213C 'sanitize':29A 'sanitize-html':2A,28A,36B,46B,108C,212C 'sanitizehtml':210C,215C 'script':94B,180C,223C,253C,317C 'show':97B 'skiptext':367C 'skiptextdepth':331C,379C 'someth':358C 'src':221C 'srcdoc':247C 'stack':149C 'still':139C 'still-open':138C 'style':224C,318C 'suggest':336C 'summari':107C 'synthet':154C 'tag':53B,263C 'text':100B,186C,194C,240C 'textarea':225C,319C 'therefor':198C 'token':283C 'treat':172C 'unclos':243C 'unwind':147C 'use':123C 'user':40B 'user-generated-html':39B 'variant':259C 'verifi':244C 'way':161C 'web':44B 'web-app':43B 'whole':236C 'without':333C 'would':96B,204C 'x':275C 'x3q4':22A 'xmp':321C","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:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","cvss_by":"security-advisories@github.com","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-x3q4-9hxx-gx8m","url":"https://github.com/advisories/GHSA-x3q4-9hxx-gx8m","severity":"medium","published_at":"2026-10-20T18:03:22+00:00","modified_at":"2026-10-20T18:03:24+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-113654","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-113654","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"}