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
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:
onclosetag: function (name, isImplied) {
if (skipText) {
if (isImplied) return; // an implied close must not end the region
skipTextDepth--;
…
References
Summary
sanitize-htmldiscards the content of every element innonTextTags. It delimits that regionusing htmlparser2's close events, and ignores the
isImpliedflag htmlparser2 passes it. Closing astill-open ancestor from inside a
<noscript>makes htmlparser2 unwind its stack past thenoscript; the synthetic implied close it emits on the way ends the discard region early, andeverything after it is treated as ordinary body content.
A browser with scripting enabled parses
<noscript>content as raw text, so the same markuprenders as inert text there. The sanitizer therefore emits elements the page itself would not have.
Reproduction
Chrome, given the same input, renders no
<img>— the whole payload is raw text inside theunclosed
<noscript>. Verified with Playwright,srcdocinto an iframe so the scripting flag is on.A
<![CDATA[variant hides the opening tag from the parser entirely and has the same effect: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 consultingisImplied.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:
References