ApostropheCMS: Mutation-XSS / allowedTags bypass via literal `</textarea/>` solidus close
Published 3 Sept 2026 · Updated 3 Sept 2026
Affected package
- Affected
- <2.17.6
- Fixed in
- 2.17.6
npm install sanitize-html@2.17.6Details
Summary
A mutation-XSS / allowedTags bypass: when textarea (or xmp) is included in allowedTags, an input containing a literal </textarea/> (a solidus right after the RCDATA end-tag name) lets non-allowed markup such as <img src=x onerror=…> pass through sanitizeHtml() live and unescaped, even though img/onerror are not in the allowlist. A spec-compliant browser executes the surviving handler — XSS. This is a literal-solidus variant that bypasses the two most recent fixes in this code area (CVE-2026-40186, CVE-2026-44990), both already applied in 2.17.5. The default configuration is not affected.
Details
sanitize-html emits the text content of HTML raw-text elements (textarea, xmp) without escaping. Two things combine:
- Parser differential: on input, htmlparser2 does NOT recognize
</textarea/>(solidus after the RCDATA end-tag name) as a close tag; it emits</textarea/><img …>as a single raw-text node. - Unescaped passthrough: the
ontexthandler (index.js~575-583) appendstextarea/xmpcontent with
result += text (no escapeHtml), assuming it is "already properly encoded" — true for entity-decoded
content (what CVE-2026-40186 fixed) but false for this mis-tokenized literal close tag.
A spec browser treats </textarea/> as a valid textarea close, so the following <img onerror> is parsed as a live element. The recent fixes addressed entity-encoding (CVE-2026-40186) and the xmp default (CVE-2026-44990); neither covers the literal-solidus mis-tokenization, so the raw passthrough still leaks.
PoC
// npm i sanitize-html@2.17.5 parse5 && node poc.js
const sanitizeHtml = require('sanitize-html');
const input = '<textarea></textarea/><img src=x onerror="alert(document.domain)">';
const opts = { allowedTags: sanitizeHtml.defaults.allowedTags.concat(['textarea']) }; // img NOT allowed
console.log(sanitizeHtml(input, opts));
// => <textarea></textarea/><img src=x onerror="alert(document.domain)"></textarea>
// the <img onerror> survives live and unescaped
console.log(sanitizeHtml(input)); // default config (no textarea allowed) => "" (safe)Re-parsing the sanitized OUTPUT with parse5 (the WHATWG HTML parser browsers/jsdom use) yields a live
<img src=x onerror=alert(document.domain)> at body level (it escaped the textarea RCDATA, not inert text) → the onerror fires in a browser. Confirmed on 2.17.5 (Node v24). A canonical poc.js is attached.
<img width="650" height="118" alt="image" src="https://github.com/user-attachments/assets/d63bb5b7-ba3e-4b0e-a821-86a453ea0352" />
Impact
Cross-site scripting (CWE-79). Requires textarea (or xmp) in allowedTags — a benign-looking, common addition in form builders, CMS, and rich-text editors. Adding a harmless tag that then enables XSS via *non-allowed* img/onerror breaks the sanitizer's core contract; the maintainers have fixed this class before (e.g. GHSA-9mrh). An attacker who can submit content rendered through such a configuration achieves stored/reflected XSS (cookie theft, session hijack). Severity Medium (default config is safe; user interaction to view the page). Suggested fix: route textarea/xmp content through escapeHtml instead of the raw passthrough, and/or fix the htmlparser2 </tag/> RCDATA end-tag tokenization to match the WHATWG spec.
Is GHSA-jxwj-j7wr-gfrw 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
- Linkgithub.com/apostrophecms/apostrophe/security/advisories/GHSA-jxwj-j7wr-gfrw
- Advisorynvd.nist.gov/vuln/detail/CVE-2026-63670
- Linkgithub.com/apostrophecms/apostrophe/pull/5501
- Linkgithub.com/apostrophecms/apostrophe/commit/eae1fb2b72ec5d1c27d0977509c6482a0408f725
- Packagegithub.com/apostrophecms/apostrophe
Advisory data from OSV.dev (opens OSV.dev in a new tab), refreshed daily.