MediumGHSA-x3q4-9hxx-gx8mCVE-2026-113654

ApostropheCMS sanitize-html: nonTextTags discard region ends early on an implied close, letting <noscript> content render

Published 20 Oct 2026 · Updated 20 Oct 2026

Affected package

Affected
<2.18.0
Fixed in
2.18.0
Update to the fixed version for your release line, for example:npm install sanitize-html@2.18.0

Details

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--;
    …

Is GHSA-x3q4-9hxx-gx8m in any of your client repos?

Checking one package by hand is fine. Checking it across forty client projects is not. NPMscan reads the lockfiles in every GitHub repo you pick, finds each project with an affected sanitize-html, and gives you the command that fixes it.

References

Advisory data from OSV.dev (opens OSV.dev in a new tab), refreshed daily.