{"id":"CVE-2026-113650","kind":"vulnerability","aliases":["CVE-2026-113650","GHSA-c9rx-x947-fp3h"],"related":[],"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 `<iframe>` 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 `<iframe>` 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' && !tagAllowed(tag) && 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('<iframe>'.repeat(1000));  // 8,000 chars: returns ''\nsanitizeHtml('<iframe>'.repeat(2000));  // 16,000 chars: throws RangeError: Maximum call stack size exceeded\nsanitizeHtml('<iframe>'.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| `'<iframe>'.repeat(1000)` | ok | ok | ok |\n| `'<iframe>'.repeat(2000)` | ok | ok | RangeError |\n| `'<iframe>'.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 = ('<iframe>' + '<b>'.repeat(3)).repeat(50) + 'x';\nconst out = sanitizeHtml(input, { allowedTags: ['b'], nestingLimit: 3 });\n// maximum nesting depth of <b> 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 `<b>` in all three versions. With\n`k` repetitions of `'<iframe>' + '<b>'.repeat(3)`, 2.18.0 returns `3 * k` nested `<b>` (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' &&\n  !tagAllowed(tag) &&\n  options.disallowedTagsMode === 'discard'\n) {\n  result += sanitizeHtml(text, options);\n}\n```\n\nBecause htmlparser2 treats `<iframe>` 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 `<iframe>`, 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.","summary":"sanitize-html 2.18.0 re-sanitizes the text of a discarded unclosed iframe by calling itself recursively with no bound, so a few kilobytes of repeated iframe tags make it throw RangeError: Maximum call stack size exceeded under default options. The same recursion also lets output exceed the configured nestingLimit, so an attacker can crash a sanitizing process or produce deeply nested HTML.","severity":"medium","cvss":5.300000190734863,"epss":0.003010000102221966,"exploited":false,"exploited_at":null,"products":[{"name":"sanitize-html","fixed":["2.18.1"],"affected":">= 2.18.0, < 2.18.1","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["cwe-674","node.js","npm","package","sanitize-html","user-generated-html"],"not_affected_if":["no-untrusted-html-input","version-2.18.1-or-later","version-below-2.18.0"],"how_to_check":"Run npm ls sanitize-html and check whether the resolved version is 2.18.0 (affected range is >= 2.18.0, < 2.18.1).\nGrep lockfiles for sanitize-html@2.18.0.\nSanitize the input sanitizeHtml('<iframe>'.repeat(2000)) in a test; a RangeError means the vulnerable code path is present.","fix":"Upgrade sanitize-html to 2.18.1 or later (fixed in 2.18.1).\nIf upgrading is not possible, pin to 2.17.7, which the report says is not affected.","status":"open","published_at":"2026-10-20T17:17:35+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","triaged_at":"2026-10-23T19:58:21+00:00","updated_at":"2026-10-23T21:57:20+00:00","category":"package","confidence":0.85,"triage":{"at":"2026-10-23T19:58:21Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade sanitize-html to 2.18.1 or later (fixed in 2.18.1).\nIf upgrading is not possible, pin to 2.17.7, which the report says is not affected.","summary":"sanitize-html 2.18.0 re-sanitizes the text of a discarded unclosed iframe by calling itself recursively with no bound, so a few kilobytes of repeated iframe tags make it throw RangeError: Maximum call stack size exceeded under default options. The same recursion also lets output exceed the configured nestingLimit, so an attacker can crash a sanitizing process or produce deeply nested HTML.","category":"package","who_cares":["npm","node.js","sanitize-html","user-generated-html"],"confidence":0.85,"analyst_note":"","how_to_check":"Run npm ls sanitize-html and check whether the resolved version is 2.18.0 (affected range is >= 2.18.0, < 2.18.1).\nGrep lockfiles for sanitize-html@2.18.0.\nSanitize the input sanitizeHtml('<iframe>'.repeat(2000)) in a test; a RangeError means the vulnerable code path is present.","needs_analyst":false,"not_affected_if":["version-below-2.18.0","version-2.18.1-or-later","no-untrusted-html-input"],"severity_if_unknown":"unknown"},"cost_usd":0.000585,"input_tokens":3498,"output_tokens":471,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-113650':24A,32A '-2026':23A,31A '-674':37B '0':422C '000':307C,314C,327C '1':171C,287C,459C '1000':305C,342C '15':464C '150':428C,465C '16':313C '160':326C '2':207C,387C,420C '2.17.4':170C,275C,338C,419C '2.17.7':168C,273C,339C,421C '2.18.0':51B,114C,196C,240C,302C,340C,427C,451C,468C '2000':312C,347C '20000':325C,352C '3':401C,412C,450C,453C,463C '5':460C '50':403C,461C '5550':148C '5564':146C '8':306C 'absent':166C 'across':212C 'affect':238C,272C 'allow':119C 'allowedtag':409C 'alreadi':373C 'also':92B,533C 'anoth':514C 'apostrophecm':1A 'appli':586C 'applic':190C 'arbitrarili':231C 'attack':101B 'b':410C 'bound':68B,153C,565C 'branch':247C,264C 'bypass':390C 'c9rx':27A 'call':63B,82B,138C,158C,181C,255C,319C,332C,370C,375C,532C 'cap':435C 'carri':575C 'caus':466C 'chang':144C 'char':308C,315C,328C 'close':562C 'configur':97B,236C 'consequ':164C 'const':295C,392C,398C,405C 'content':130C 'count':222C,546C 'craft':205C 'crash':103B 'current':242C,577C 'cve':22A,30A 'cwe':36B 'deep':232C,374C 'deepli':109B 'default':87B,125C,187C,292C 'depend':358C 'depth':221C,415C,462C,538C,578C 'direct':555C 'disallowedtagsmod':121C 'discard':15A,59B,123C,214C,270C,481C 'effect':564C 'element':494C 'els':474C 'enclos':552C 'enforc':21A,211C 'escap':424C 'exampl':378C 'exceed':85B,95B,184C,322C,335C 'fallback':12A,423C,543C,571C 'fix':147C 'fp3h':29A 'frame':521C 'fresh':161C 'full':524C 'generat':46B 'ghsa':26A 'ghsa-c9rx-x947-fp3h':25A 'handler':263C,471C 'html':4A,35A,43B,47B,50B,111B,194C,300C,397C 'htmlparser2':487C 'ifram':16A,61B,75B,215C,218C,266C,431C,477C,500C,529C 'input':206C,280C,337C,399C,408C,507C 'insid':379C,541C 'js':294C,391C,473C 'k':446C,454C,458C 'kilobyt':72B,174C 'latest':243C 'let':93B 'limit':237C 'lower':366C 'made':199C 'main':246C 'make':77B 'maximum':81B,180C,318C,331C,413C 'measur':456C 'meet':513C 'nest':110B,157C,217C,230C,414C,440C,455C,531C,581C 'nestinglimit':19A,98B,208C,388C,411C,548C,585C 'node.js':38B,284C 'npm':39B 'ok':343C,344C,345C,348C,349C,353C,354C 'one':519C,523C 'ontext':262C,470C 'option':88B,142C,188C,293C,434C,485C 'options.disallowedtagsmode':269C,480C 'output':94B,227C,437C,590C 'overflow':18A,289C 'packag':40B 'packages/sanitize-html/index.js':259C 'pars':510C,527C 'pass':385C 'patch':558C 'per':528C 'possibl':554C 'process':106B,569C 'produc':108B 'rangeerror':80B,179C,317C,330C,350C,355C 'raw':128C,492C 'raw-text':127C,491C 're':9A,53B,133C,509C,526C 're-pars':508C,525C 're-sanit':8A,52B,132C 'recurs':6A,65B,91B,137C,150C,254C,567C,573C 'regardless':233C 'remain':361C 'remaind':504C 'render':384C 'repeat':74B,304C,311C,324C,341C,346C,351C,400C,402C,449C 'repetit':447C 'reproduct':286C,386C 'requir':297C,394C 'restart':219C 'result':336C,482C 'return':309C,452C 'sanit':3A,10A,42B,49B,54B,105B,134C,192C,299C,396C,582C 'sanitize':34A 'sanitize-html':2A,33A,41B,48B,298C,395C 'sanitizehtml':140C,296C,303C,310C,323C,368C,393C,407C,483C 'server':382C 'server-sid':381C 'side':383C 'sinc':113C 'size':84B,183C,321C,334C 'small':204C 'stack':17A,83B,182C,288C,320C,333C,362C,376C,520C 'start':155C,534C 'state':162C 'still':257C 'summari':112C 'tag':76B,177C,265C,268C,476C,479C,540C,553C 'tagallow':267C,478C 'test':276C,282C 'text':56B,129C,141C,426C,484C,493C,496C 'three':439C,443C 'threshold':357C 'throw':79B,178C,201C,316C,329C 'time':250C 'togeth':549C 'treat':488C 'two':163C 'unbound':5A 'unclos':60B,176C,499C,515C 'untrust':193C 'user':45B 'user-generated-html':44B 'v26.7.0':285C 'version':239C,444C 'whole':503C,589C 'without':429C,572C 'would':561C 'write':252C 'x':404C 'x947':28A 'zero':224C","nicknames":[],"mitigations":[],"who_affected":null,"tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-20T17:17:35+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-c9rx-x947-fp3h","url":"https://github.com/advisories/GHSA-c9rx-x947-fp3h","severity":"medium","published_at":"2026-10-21T17:05:01+00:00","modified_at":"2026-10-21T17:05:02+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","fetched_at":"2026-10-23T19:11:32+00:00","note":null},{"source":"nvd","external_id":"CVE-2026-113650","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-113650","severity":null,"published_at":"2026-10-20T17:17:35+00:00","modified_at":"2026-10-20T19:53:10+00:00","first_seen_at":"2026-10-23T21:57:20+00:00","fetched_at":"2026-10-23T22:28:58+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}