GHSA-X3Q4-9HXX-GX8M
Vulnerability from github – Published: 2026-10-20 18:03 – Updated: 2026-10-20 18:03Summary
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 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:
onclosetag: function (name, isImplied) {
if (skipText) {
if (isImplied) return; // an implied close must not end the region
skipTextDepth--;
…
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "sanitize-html"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.18.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-113654"
],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-20T18:03:22Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\n`sanitize-html` discards the *content* of every element in `nonTextTags`. It delimits that region\nusing htmlparser2\u0027s close events, and ignores the `isImplied` flag htmlparser2 passes it. Closing a\nstill-open **ancestor** from inside a `\u003cnoscript\u003e` 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 `\u003cnoscript\u003e` 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(\u0027\u003cdiv\u003e\u003cnoscript\u003e\u003c/div\u003e\u003cimg src=\"http://evil.test/t.gif\"\u003e\u003c/noscript\u003e\u0027, {\n allowedTags: [\"div\", \"img\"],\n allowedAttributes: { img: [\"src\"] },\n nonTextTags: [\"script\", \"style\", \"textarea\", \"option\", \"noscript\"],\n});\n// =\u003e \u0027\u003cdiv\u003e\u003c/div\u003e\u003cimg src=\"http://evil.test/t.gif\" /\u003e\u0027\n```\n\nChrome, given the same input, renders **no** `\u003cimg\u003e` \u2014 the whole payload is raw text inside the\nunclosed `\u003cnoscript\u003e`. Verified with Playwright, `srcdoc` into an iframe so the scripting flag is on.\n\nA `\u003c![CDATA[` variant hides the opening tag from the parser entirely and has the same effect:\n\n```\n\u003cp\u003eHi\u003c/p\u003e\u003c![CDATA[ x \u003e \u003cnoscript\u003e]]\u003e\u003ca href=\"\u2026\"\u003eReset your password\u003c/a\u003e\u003cimg src=\"\u2026beacon.gif\"\u003e\u003c/noscript\u003e\n```\n\nhtmlparser2 runs a CDATA token to `]]\u003e`; Chrome ends a bogus comment at the first `\u003e`, so the\n`\u003cnoscript\u003e` 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` \u2014 `nonTextTagsArray` (the default list, `[\u0027script\u0027,\u0027style\u0027,\u0027textarea\u0027,\u0027option\u0027,\u0027xmp\u0027]`).\n- `:638` \u2014 `onclosetag: function (name, isImplied)` \u2014 receives the flag.\n- `:644` \u2014 `skipTextDepth--` \u2014 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 \u2026\n```",
"id": "GHSA-x3q4-9hxx-gx8m",
"modified": "2026-10-20T18:03:22Z",
"published": "2026-10-20T18:03:22Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-x3q4-9hxx-gx8m"
},
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/commit/9500b8b0e1d630d5e07fbb5fd2c25ed0943845ce"
},
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/commit/f84fd41cbf6c22eb8d0008fc9c25df1f7130b350"
},
{
"type": "PACKAGE",
"url": "https://github.com/apostrophecms/apostrophe"
},
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/releases/tag/sanitize-html@2.18.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "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",
"type": "CVSS_V4"
}
],
"summary": "ApostropheCMS sanitize-html: nonTextTags discard region ends early on an implied close, letting \u003cnoscript\u003e content render"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.