{"id":"CVE-2026-113650","aliases":["EUVD-2026-105176","GHSA-c9rx-x947-fp3h"],"status":"published","title":"ApostropheCMS sanitize-html: unbounded recursion when re-sanitizing the fallback of a discarded iframe (stack overflow, nestingLimit not enforced)","description":"ApostropheCMS is an open-source Node.js content management system. In sanitize-html 2.18.0, when an \u003ciframe\u003e is not allowed and disallowedTagsMode is 'discard' (the defaults), its raw-text content is re-sanitized by a recursive call to sanitizeHtml(text, options). The recursion has no bound and starts each nested call with a fresh state. This vulnerability is fixed in 2.18.1.","severity":{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","level":"medium","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-12","score":0.0048,"percentile":0.41906,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-674"],"published":"2026-10-20T16:19:12Z","modified":"2026-10-30T02:30:51Z","fixes":[{"ecosystem":"npm","package":"sanitize-html","fixed_in":["2.18.1"],"sources":["ghsa","osv"]}],"affected":[{"source":"ghsa","ecosystem":"npm","package":"sanitize-html","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"2.18.0"},{"fixed":"2.18.1"}]}]},{"source":"osv","ecosystem":"npm","package":"sanitize-html","ranges":[{"type":"SEMVER","events":[{"introduced":"2.18.0"},{"fixed":"2.18.1"}]}]}],"products":[{"vendor":"apostrophecms","product":"apostrophe"},{"vendor":"apostrophecms","product":"sanitize-html"}],"references":[{"url":"https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-c9rx-x947-fp3h","tags":["x_refsource_CONFIRM","exploit","advisory","web"]},{"url":"https://github.com/apostrophecms/apostrophe/commit/06e4b377f3c360f9e89c32dccd7e505ad5ddf498","tags":["x_refsource_MISC","fix","web"]},{"url":"https://github.com/apostrophecms/apostrophe/commit/40b989b1a9dd23d30029b8ce7668634265cfe5ea","tags":["x_refsource_MISC","fix","web"]},{"url":"https://github.com/apostrophecms/apostrophe/releases/tag/sanitize-html@2.18.1","tags":["x_refsource_MISC","web"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-113650","tags":["advisory"]},{"url":"https://github.com/advisories/GHSA-c9rx-x947-fp3h"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/113xxx/CVE-2026-113650.json","tags":["advisory"]},{"url":"https://github.com/apostrophecms/apostrophe","tags":["package"]}],"sources":{"cve":[{"id":"CVE-2026-113650","title":"ApostropheCMS sanitize-html: unbounded recursion when re-sanitizing the fallback of a discarded iframe (stack overflow, nestingLimit not enforced)","description":"ApostropheCMS is an open-source Node.js content management system. In sanitize-html 2.18.0, when an \u003ciframe\u003e is not allowed and disallowedTagsMode is 'discard' (the defaults), its raw-text content is re-sanitized by a recursive call to sanitizeHtml(text, options). The recursion has no bound and starts each nested call with a fresh state. This vulnerability is fixed in 2.18.1.","status":"published","published":"2026-10-20T16:19:12.1Z","modified":"2026-10-28T14:02:38.5Z","cvss":[{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","level":"medium","assessor":"cna"}],"cwes":["CWE-674"],"updated_at":"2026-11-02T13:44:29.519518Z"}],"euvd":[{"id":"EUVD-2026-105176","description":"ApostropheCMS is an open-source Node.js content management system. In sanitize-html 2.18.0, when an \u003ciframe\u003e is not allowed and disallowedTagsMode is 'discard' (the defaults), its raw-text content is re-sanitized by a recursive call to sanitizeHtml(text, options). The recursion has no bound and starts each nested call with a fresh state. This vulnerability is fixed in 2.18.1.","status":"published","published":"2026-10-20T16:19:12Z","modified":"2026-10-28T14:02:38Z","cvss":[{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","level":"medium"}],"updated_at":"2026-11-02T16:59:20.750321Z"}],"ghsa":[{"id":"GHSA-c9rx-x947-fp3h","title":"ApostropheCMS sanitize-html: unbounded recursion when re-sanitizing the fallback of a discarded iframe (stack overflow, nestingLimit not enforced)","description":"### Summary\n\nSince 2.18.0, when an `\u003ciframe\u003e` is not allowed and `disallowedTagsMode` is `'discard'` (the defaults),\nits raw-text content is re-sanitized by a recursive call to `sanitizeHtml(text, options)` (the change\nfrom #5564, fixing #5550). The recursion has no bound and starts each nested call with a fresh state.\nTwo consequences, both absent from 2.17.7 and 2.17.4:\n\n1. A few kilobytes of unclosed `\u003ciframe\u003e` tags throw `RangeError: Maximum call stack size exceeded`\n   with the default options. Any application that sanitizes untrusted HTML with 2.18.0 can be made to\n   throw on a small crafted input.\n2. `nestingLimit` is not enforced across a discarded iframe: each nested iframe restarts the depth\n   count at zero, so the output can be nested arbitrarily deep regardless of the configured limit.\n\n### Affected versions\n\n- 2.18.0 (the current `latest`), and the `main` branch at the time of writing (the recursive call is\n  still in `packages/sanitize-html/index.js`, in the `ontext` handler, branch\n  `tag === 'iframe' \u0026\u0026 !tagAllowed(tag) \u0026\u0026 options.disallowedTagsMode === 'discard'`).\n- Not affected: 2.17.7 and 2.17.4 (tested with the same inputs below).\n\nTested with Node.js v26.7.0.\n\n### Reproduction 1: stack overflow with the default options\n\n```js\nconst sanitizeHtml = require('sanitize-html');\n\n// with 2.18.0:\nsanitizeHtml('\u003ciframe\u003e'.repeat(1000));  // 8,000 chars: returns ''\nsanitizeHtml('\u003ciframe\u003e'.repeat(2000));  // 16,000 chars: throws RangeError: Maximum call stack size exceeded\nsanitizeHtml('\u003ciframe\u003e'.repeat(20000)); // 160,000 chars: throws RangeError: Maximum call stack size exceeded\n```\n\nResults:\n\n| input | 2.17.4 | 2.17.7 | 2.18.0 |\n|---|---|---|---|\n| `'\u003ciframe\u003e'.repeat(1000)` | ok | ok | ok |\n| `'\u003ciframe\u003e'.repeat(2000)` | ok | ok | RangeError |\n| `'\u003ciframe\u003e'.repeat(20000)` | ok | ok | RangeError |\n\nThe threshold depends on the remaining stack, so it is lower when `sanitizeHtml` is called from an\nalready deep call stack (for example inside a server-side rendering pass).\n\n### Reproduction 2: `nestingLimit` is bypassed\n\n```js\nconst sanitizeHtml = require('sanitize-html');\n\nconst input = ('\u003ciframe\u003e' + '\u003cb\u003e'.repeat(3)).repeat(50) + 'x';\nconst out = sanitizeHtml(input, { allowedTags: ['b'], nestingLimit: 3 });\n// maximum nesting depth of \u003cb\u003e in `out`:\n//   2.17.4: 2    2.17.7: 0 (fallback escaped as text)    2.18.0: 150\n```\n\nWithout an iframe, the same options cap the output at three nested `\u003cb\u003e` in all three versions. With\n`k` repetitions of `'\u003ciframe\u003e' + '\u003cb\u003e'.repeat(3)`, 2.18.0 returns `3 * k` nested `\u003cb\u003e` (measured for\nk = 1, 5, 50: depth 3, 15, 150).\n\n### Cause\n\nIn 2.18.0 the `ontext` handler does:\n\n```js\n} else if (\n  tag === 'iframe' \u0026\u0026\n  !tagAllowed(tag) \u0026\u0026\n  options.disallowedTagsMode === 'discard'\n) {\n  result += sanitizeHtml(text, options);\n}\n```\n\nBecause htmlparser2 treats `\u003ciframe\u003e` as a raw-text element, the text of an unclosed iframe is the whole\nremainder of the input; re-parsing it can meet another unclosed `\u003ciframe\u003e`, and so on, one stack frame\n(and one full re-parse) per iframe. The nested call also starts with its own `depth`, so tags inside the\nfallback are not counted against `nestingLimit` together with the enclosing tags.\n\n### Possible directions\n\nNot a patch, only what would close both effects: bound the recursion (or process the fallback without\nrecursing), and carry the current depth into the nested sanitization so that `nestingLimit` applies to\nthe whole output.","status":"published","published":"2026-10-21T17:05:01Z","modified":"2026-10-21T17:05:02Z","cvss":[{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","level":"medium"}],"cwes":["CWE-674"],"updated_at":"2026-11-02T14:41:34.690842Z"}],"nvd":[{"id":"CVE-2026-113650","description":"ApostropheCMS is an open-source Node.js content management system. In sanitize-html 2.18.0, when an \u003ciframe\u003e is not allowed and disallowedTagsMode is 'discard' (the defaults), its raw-text content is re-sanitized by a recursive call to sanitizeHtml(text, options). The recursion has no bound and starts each nested call with a fresh state. This vulnerability is fixed in 2.18.1.","status":"published","published":"2026-10-20T17:17:35.807Z","modified":"2026-10-28T14:16:56.61Z","cvss":[{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","level":"medium","assessor":"secondary"}],"cwes":["CWE-674"],"updated_at":"2026-11-02T14:13:01.161484Z"}],"osv":[{"id":"CVE-2026-113650","title":"ApostropheCMS sanitize-html: unbounded recursion when re-sanitizing the fallback of a discarded iframe (stack overflow, nestingLimit not enforced)","description":"ApostropheCMS is an open-source Node.js content management system. In sanitize-html 2.18.0, when an \u003ciframe\u003e is not allowed and disallowedTagsMode is 'discard' (the defaults), its raw-text content is re-sanitized by a recursive call to sanitizeHtml(text, options). The recursion has no bound and starts each nested call with a fresh state. This vulnerability is fixed in 2.18.1.","status":"published","published":"2026-10-20T16:19:12.1Z","modified":"2026-10-30T02:30:51.844633234Z","cvss":[{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","level":"medium"}],"cwes":["CWE-674"],"updated_at":"2026-11-02T15:44:56.310988Z"},{"id":"GHSA-c9rx-x947-fp3h","title":"ApostropheCMS sanitize-html: unbounded recursion when re-sanitizing the fallback of a discarded iframe (stack overflow, nestingLimit not enforced)","description":"### Summary\n\nSince 2.18.0, when an `\u003ciframe\u003e` is not allowed and `disallowedTagsMode` is `'discard'` (the defaults),\nits raw-text content is re-sanitized by a recursive call to `sanitizeHtml(text, options)` (the change\nfrom #5564, fixing #5550). The recursion has no bound and starts each nested call with a fresh state.\nTwo consequences, both absent from 2.17.7 and 2.17.4:\n\n1. A few kilobytes of unclosed `\u003ciframe\u003e` tags throw `RangeError: Maximum call stack size exceeded`\n   with the default options. Any application that sanitizes untrusted HTML with 2.18.0 can be made to\n   throw on a small crafted input.\n2. `nestingLimit` is not enforced across a discarded iframe: each nested iframe restarts the depth\n   count at zero, so the output can be nested arbitrarily deep regardless of the configured limit.\n\n### Affected versions\n\n- 2.18.0 (the current `latest`), and the `main` branch at the time of writing (the recursive call is\n  still in `packages/sanitize-html/index.js`, in the `ontext` handler, branch\n  `tag === 'iframe' \u0026\u0026 !tagAllowed(tag) \u0026\u0026 options.disallowedTagsMode === 'discard'`).\n- Not affected: 2.17.7 and 2.17.4 (tested with the same inputs below).\n\nTested with Node.js v26.7.0.\n\n### Reproduction 1: stack overflow with the default options\n\n```js\nconst sanitizeHtml = require('sanitize-html');\n\n// with 2.18.0:\nsanitizeHtml('\u003ciframe\u003e'.repeat(1000));  // 8,000 chars: returns ''\nsanitizeHtml('\u003ciframe\u003e'.repeat(2000));  // 16,000 chars: throws RangeError: Maximum call stack size exceeded\nsanitizeHtml('\u003ciframe\u003e'.repeat(20000)); // 160,000 chars: throws RangeError: Maximum call stack size exceeded\n```\n\nResults:\n\n| input | 2.17.4 | 2.17.7 | 2.18.0 |\n|---|---|---|---|\n| `'\u003ciframe\u003e'.repeat(1000)` | ok | ok | ok |\n| `'\u003ciframe\u003e'.repeat(2000)` | ok | ok | RangeError |\n| `'\u003ciframe\u003e'.repeat(20000)` | ok | ok | RangeError |\n\nThe threshold depends on the remaining stack, so it is lower when `sanitizeHtml` is called from an\nalready deep call stack (for example inside a server-side rendering pass).\n\n### Reproduction 2: `nestingLimit` is bypassed\n\n```js\nconst sanitizeHtml = require('sanitize-html');\n\nconst input = ('\u003ciframe\u003e' + '\u003cb\u003e'.repeat(3)).repeat(50) + 'x';\nconst out = sanitizeHtml(input, { allowedTags: ['b'], nestingLimit: 3 });\n// maximum nesting depth of \u003cb\u003e in `out`:\n//   2.17.4: 2    2.17.7: 0 (fallback escaped as text)    2.18.0: 150\n```\n\nWithout an iframe, the same options cap the output at three nested `\u003cb\u003e` in all three versions. With\n`k` repetitions of `'\u003ciframe\u003e' + '\u003cb\u003e'.repeat(3)`, 2.18.0 returns `3 * k` nested `\u003cb\u003e` (measured for\nk = 1, 5, 50: depth 3, 15, 150).\n\n### Cause\n\nIn 2.18.0 the `ontext` handler does:\n\n```js\n} else if (\n  tag === 'iframe' \u0026\u0026\n  !tagAllowed(tag) \u0026\u0026\n  options.disallowedTagsMode === 'discard'\n) {\n  result += sanitizeHtml(text, options);\n}\n```\n\nBecause htmlparser2 treats `\u003ciframe\u003e` as a raw-text element, the text of an unclosed iframe is the whole\nremainder of the input; re-parsing it can meet another unclosed `\u003ciframe\u003e`, and so on, one stack frame\n(and one full re-parse) per iframe. The nested call also starts with its own `depth`, so tags inside the\nfallback are not counted against `nestingLimit` together with the enclosing tags.\n\n### Possible directions\n\nNot a patch, only what would close both effects: bound the recursion (or process the fallback without\nrecursing), and carry the current depth into the nested sanitization so that `nestingLimit` applies to\nthe whole output.","status":"published","published":"2026-10-21T17:05:01Z","modified":"2026-10-21T17:15:08.634885273Z","cvss":[{"version":"3.1","score":5.3,"vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","level":"medium"}],"cwes":["CWE-674"],"updated_at":"2026-11-02T15:46:44.927642Z"}]}}
