CVE-2026-40186
Description
ApostropheCMS is an open-source Node.js content management system. A regression introduced in commit 49d0bb7, included in versions 2.17.1 of the ApostropheCMS-maintained sanitize-html package bypasses allowedTags enforcement for text inside nonTextTagsArray elements (textarea and option). ApostropheCMS version 4.28.0 is affected through its dependency on the vulnerable sanitize-html version. The code at packages/sanitize-html/index.js:569-573 incorrectly assumes that htmlparser2 does not decode entities inside these elements and skips escaping, but htmlparser2 10.x does decode entities before passing text to the ontext callback. As a result, entity-encoded HTML is decoded by the parser and then written directly to the output as literal HTML characters, completely bypassing the allowedTags filter. An attacker can inject arbitrary tags including XSS payloads through any allowed option or textarea element using entity encoding. This affects non-default configurations where option or textarea are included in allowedTags, which is common in form builders and CMS platforms. This issue has been fixed in version 2.17.2 of sanitize-html and 4.29.0 of ApostropheCMS.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
sanitize-htmlnpm | >= 2.17.2, < 2.17.3 | 2.17.3 |
Affected products
2- cpe:2.3:a:apostrophecms:apostrophecms:4.29.0:*:*:*:*:*:*:*
- cpe:2.3:a:apostrophecms:sanitize-html:*:*:*:*:*:node.js:*:*Range: <=2.17.1
Patches
17ca2d16237c7Merge commit from fork
3 files changed · +39 −4
.changeset/pretty-mirrors-refuse.md+5 −0 added@@ -0,0 +1,5 @@ +--- +"sanitize-html": patch +--- + +Fix vulnerability introduced in version 2.17.2 that allowed XSS attacks if the developer chose to permit `option` tags. There was no vulnerability when not explicitly allowing `option` tags.
packages/sanitize-html/index.js+8 −4 modified@@ -566,10 +566,14 @@ function sanitizeHtml(html, options, _recursing) { // your concern, don't allow them. The same is essentially true for style tags // which have their own collection of XSS vectors. result += text; - } else if ((options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (nonTextTagsArray.indexOf(tag) !== -1)) { - // htmlparser2 does not decode entities inside raw text elements like - // textarea and option. The text is already properly encoded, so pass - // it through without additional escaping to avoid double-encoding. + } else if ((options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (tag === 'textarea' || tag === 'xmp')) { + // htmlparser2 treats <textarea> and <xmp> as raw text elements and + // does NOT decode entities inside them. The text is already properly + // encoded, so pass it through without additional escaping to avoid + // double-encoding. Other "nonTextTags" like <option> are not raw text + // elements in htmlparser2, so their contents are decoded and must be + // escaped below like any other text (important to prevent XSS via + // entity-encoded payloads such as <option><script>...</script></option>). result += text; } else if (!addedText) { const escaped = escapeHtml(text, false);
packages/sanitize-html/test/test.js+26 −0 modified@@ -1896,4 +1896,30 @@ describe('sanitizeHtml', function() { assert.equal(sanitizedHtmlPreservedAttrsTrue, sanitizedHtmlPreservedAttrsFalse); }); + it('should not allow script tag injection via escaped entities in option tag', () => { + const inputHtml = '<option><script>alert(1)</script></option>'; + const result = sanitizeHtml(inputHtml, { allowedTags: ['option'] }); + assert.strictEqual(result, '<option><script>alert(1)</script></option>'); + }); + it('should not double-encode entities inside an allowed option element', function() { + assert.equal( + sanitizeHtml('<option><div>hello</div>&amp;</option>', + { allowedTags: [ 'option' ] } + ), '<option><div>hello</div>&amp;</option>' + ); + }); + it('should not double-encode entities inside an allowed xmp element', function() { + assert.equal( + sanitizeHtml('<xmp><div>hello</div>&amp;</xmp>', + { allowedTags: [ 'xmp' ] } + ), '<xmp><div>hello</div>&amp;</xmp>' + ); + }); + it('should correctly maintain escaping when allowing an xmp element', function() { + assert.equal( + sanitizeHtml('!<xmp></xmp><svg/onload=prompt`xs`></xmp>!', + { allowedTags: [ 'xmp' ] } + ), '!<xmp></xmp><svg/onload=prompt`xs`></xmp>!' + ); + }); });
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/apostrophecms/apostrophe/commit/7ca2d16237c72718ef7e5c7ae0458e6027ac4f64nvdPatchWEB
- github.com/apostrophecms/apostrophe/security/advisories/GHSA-9mrh-v2v3-xpfmnvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-9mrh-v2v3-xpfmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-40186ghsaADVISORY
News mentions
0No linked articles in our index yet.