GHSA-x3q4-9hxx-gx8m: XSS
Summary
sanitize-html discards the content of every element in nonTextTags. It delimits that region using htmlparser2's close events, and ignores the isImplied flag htmlparser2 passes it. Closing a still-open ancestor from inside a <noscript> makes htmlparser2 unwind its stack past the noscript; the synthetic implied close it emits on the way ends the discard region early, and everything after it is treated as ordinary body content.
A browser with scripting enabled parses <noscript> content as raw text, so the same markup renders as inert text there. The sanitizer therefore emits elements the page itself would not have.
Reproduction
js const sanitizeHtml = require("sanitize-html");
sanitizeHtml('<div><noscript></div><img src="http://evil.test/t.gif"></noscript>', { allowedTags: ["div", "img"], allowedAttributes: { img: ["src"] }, nonTextTags: ["script", "style", "textarea", "option", "noscript"], }); // => '<div></div><img src="http://evil.test/t.gif" />'
Chrome, given the same input, renders no <img> — the whole payload is raw text inside the unclosed <noscript>. Verified with Playwright, srcdoc into an iframe so the scripting flag is on.
A <![CDATA[ variant hides the opening tag from the parser entirely and has the same effect:
<p>Hi</p><![CDATA[ x > <noscript>]]><a href="…">Reset your password</a><img src="…beacon.gif"></noscript>
htmlparser2 runs a CDATA token to ]]>; Chrome ends a bogus comment at the first >, so the <noscript> after it is real to the browser and invisible to the parser.
Where the behaviour is
index.js:
- :154 — nonTextTagsArray (the default list, ['script','style','textarea','option','xmp']). - :638 — onclosetag: function (name, isImplied) — receives the flag. - :644 — skipTextDepth-- — decrements without consulting isImplied.
Suggested fix
Do not end a discard region on an implied close. htmlparser2 already distinguishes them; the flag is passed and discarded. Something close to:
js onclosetag: function (name, isImplied) { if (skipText) { if (isImplied) return; // an implied close must not end the region skipTextDepth--; …
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/sanitize-htmlto a version that resolves this vulnerability.Fixed in 2.18.0 - Compensating control
Update the htmlparser2 close-event handling so an implied close does not end the discard region: in onclosetag(name, isImplied), return immediately when isImplied is true before executing skipTextDepth--.
Event History
Frequently Asked Questions
Which deployments are exposed to this parsing discrepancy?
Deployments that sanitize attacker-controlled HTML with noscript included in nonTextTags are exposed when malformed markup can close an open ancestor from inside a noscript element. The demonstrated configuration also allows div and img tags and the img src attribute.
What does an attacker need to supply to trigger the issue?
The attacker needs to provide malformed HTML containing a noscript element and a closing tag for a still-open ancestor within it. This causes htmlparser2 to emit an implied close that ends sanitize-html's discard region, allowing subsequent permitted markup to be emitted.
Does the payload behave the same way when rendered directly by a browser?
No. With scripting enabled, browsers parse noscript content as raw text in the described case, so the markup that sanitize-html emits is inert when the original input is rendered directly. The risk arises because the sanitizer's output contains elements that the browser would not have created from the original input.