CVE-2026-113650

mediumpriority · track · 0EPSS 0.004 · 34 %ileUS
grimoire priorityP3 · Track
Exploitable?
—
no known exploitation
Likely / severe?
0.40% (34th) EPSS
CVSS 5.3 · medium
Is it mine?
1 products
Check your exposure
Who says so?
1 sources
osv

EPSS · scored 2026-10-28

ApostropheCMS is an open-source Node.js content management system. In sanitize-html 2.18.0, when an <iframe> 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.

01

cvss

Technical severity, 0–10. One row per scoring source.

5.3
010

base score 0–10 · higher = more severe · hover a vector to decode

v3.15.3mediumCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:Lsecondary · security-advisories@github.comexploitability 3.9 · impact 1.4
v3.15.3mediumCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:Lsecondary · OSV
v3.14.9mediumCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L/E:Usecondary · CVSS-BT (t0sche)
02

ssvc

A stakeholder decision — act, attend, or track — from exploitation and impact.

Exploitation: poc · Automatable: yes · Technical Impact: partialcoordinatorcisa_ssvcUS
03

weaknesses

The CWE weakness classes this flaw belongs to.

04

affected products (1)

Products and version ranges known to be affected.

05

advisories (1)

Vendor and ecosystem advisories describing this.

  • GHSA-c9rx-x947-fp3hApostropheCMS sanitize-html: unbounded recursion when re-sanitizing the fallback of a discarded iframe (stack overflow, nestingLimit not enforced)2026-10-21ghsa · osvUS

    ### Summary Since 2.18.0, when an `<iframe>` 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 change from #5564, fixing #5550). The recursion has no bound and starts each nested call with a fresh state. Two consequences, both absent from 2.17.7 and 2.17.4: 1. A few kilobytes of unclosed `<iframe>` tags throw `RangeError: Maximum call stack size exceeded` with the default options. Any application that sanitizes untrusted HTML with 2.18.0 can be made to throw on a small crafted input. 2. `nestingLimit` is not enforced across a discarded iframe: each nested iframe restarts the depth count at zero, so the output can be nested arbitrarily deep regardless of the configured limit. ### Affected versions - 2.18.0 (the current `latest`), and the `main` branch at the time of writing (the recursive call is still in `packages/sanitize-html/index.js`, in the `ontext` handler, branch `tag === 'iframe' && !tagAllowed(tag) && options.disallowedTagsMode === 'discard'`). - Not affected: 2.17.7 and 2.17.4 (tested with the same inputs below). Tested with Node.js v26.7.0. ### Reproduction 1: stack overflow with the default options ```js const sanitizeHtml = require('sanitize-html'); // with 2.18.0: sanitizeHtml('<iframe>'.repeat(1000)); // 8,000 chars: returns '' sanitizeHtml('<iframe>'.repeat(2000)); // 16,000 chars: throws RangeError: Maximum call stack size exceeded sanitizeHtml('<iframe>'.repeat(20000)); // 160,000 chars: throws RangeError: Maximum call stack size exceeded ``` Results: | input | 2.17.4 | 2.17.7 | 2.18.0 | |---|---|---|---| | `'<iframe>'.repeat(1000)` | ok | ok | ok | | `'<iframe>'.repeat(2000)` | ok | ok | RangeError | | `'<iframe>'.repeat(20000)` | ok | ok | RangeError | The threshold depends on the remaining stack, so it is lower when `sanitizeHtml` is called from an already deep call stack (for example inside a server-side rendering pass). ### Reproduction 2: `nestingLimit` is bypassed ```js const sanitizeHtml = require('sanitize-html'); const input = ('<iframe>' + '<b>'.repeat(3)).repeat(50) + 'x'; const out = sanitizeHtml(input, { allowedTags: ['b'], nestingLimit: 3 }); // maximum nesting depth of <b> in `out`: // 2.17.4: 2 2.17.7: 0 (fallback escaped as text) 2.18.0: 150 ``` Without an iframe, the same options cap the output at three nested `<b>` in all three versions. With `k` repetitions of `'<iframe>' + '<b>'.repeat(3)`, 2.18.0 returns `3 * k` nested `<b>` (measured for k = 1, 5, 50: depth 3, 15, 150). ### Cause In 2.18.0 the `ontext` handler does: ```js } else if ( tag === 'iframe' && !tagAllowed(tag) && options.disallowedTagsMode === 'discard' ) { result += sanitizeHtml(text, options); } ``` Because htmlparser2 treats `<iframe>` as a raw-text element, the text of an unclosed iframe is the whole remainder of the input; re-parsing it can meet another unclosed `<iframe>`, and so on, one stack frame (and one full re-parse) per iframe. The nested call also starts with its own `depth`, so tags inside the fallback are not counted against `nestingLimit` together with the enclosing tags. ### Possible directions Not a patch, only what would close both effects: bound the recursion (or process the fallback without recursing), and carry the current depth into the nested sanitization so that `nestingLimit` applies to the whole output.

06

sources

Which sources assert this record, and when.

published 2026-10-20modified 2026-10-28
07

references (4) nvd

External citations — patches, advisories, analysis.

scry

Attackers weaponise vulnerabilities like this against internet-exposed services. See what is exposed on your perimeter with a free passive scan.

Free scan