GHSA-x3q4-9hxx-gx8m: XSS

Published Oct 20, 2026
·
Updated

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

1 affected componentFixes available
npm/sanitize-html<2.18.0
2.18.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/sanitize-html to a version that resolves this vulnerability.

    Fixed in 2.18.0
  2. 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

Oct 20, 2026
Advisory Published
via GitHub·06:03 PM
Data Sourced
via GitHub·06:03 PM
DescriptionWeaknessAffected Software

Frequently Asked Questions

1

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.

2

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.

3

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.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203