GHSA-C9RX-X947-FP3H

Vulnerability from github – Published: 2026-10-21 17:05 – Updated: 2026-10-21 17:05
VLAI
Summary
ApostropheCMS sanitize-html: unbounded recursion when re-sanitizing the fallback of a discarded iframe (stack overflow, nestingLimit not enforced)
Details

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

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

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:

} 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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "sanitize-html"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "2.18.0"
            },
            {
              "fixed": "2.18.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-113650"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-674"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-21T17:05:01Z",
    "nvd_published_at": "2026-10-20T17:17:35Z",
    "severity": "MODERATE"
  },
  "details": "### Summary\n\nSince 2.18.0, when an `\u003ciframe\u003e` is not allowed and `disallowedTagsMode` is `\u0027discard\u0027` (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 === \u0027iframe\u0027 \u0026\u0026 !tagAllowed(tag) \u0026\u0026 options.disallowedTagsMode === \u0027discard\u0027`).\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(\u0027sanitize-html\u0027);\n\n// with 2.18.0:\nsanitizeHtml(\u0027\u003ciframe\u003e\u0027.repeat(1000));  // 8,000 chars: returns \u0027\u0027\nsanitizeHtml(\u0027\u003ciframe\u003e\u0027.repeat(2000));  // 16,000 chars: throws RangeError: Maximum call stack size exceeded\nsanitizeHtml(\u0027\u003ciframe\u003e\u0027.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| `\u0027\u003ciframe\u003e\u0027.repeat(1000)` | ok | ok | ok |\n| `\u0027\u003ciframe\u003e\u0027.repeat(2000)` | ok | ok | RangeError |\n| `\u0027\u003ciframe\u003e\u0027.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(\u0027sanitize-html\u0027);\n\nconst input = (\u0027\u003ciframe\u003e\u0027 + \u0027\u003cb\u003e\u0027.repeat(3)).repeat(50) + \u0027x\u0027;\nconst out = sanitizeHtml(input, { allowedTags: [\u0027b\u0027], 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 `\u0027\u003ciframe\u003e\u0027 + \u0027\u003cb\u003e\u0027.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 === \u0027iframe\u0027 \u0026\u0026\n  !tagAllowed(tag) \u0026\u0026\n  options.disallowedTagsMode === \u0027discard\u0027\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.",
  "id": "GHSA-c9rx-x947-fp3h",
  "modified": "2026-10-21T17:05:01Z",
  "published": "2026-10-21T17:05:01Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-c9rx-x947-fp3h"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-113650"
    },
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/commit/06e4b377f3c360f9e89c32dccd7e505ad5ddf498"
    },
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/commit/40b989b1a9dd23d30029b8ce7668634265cfe5ea"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/apostrophecms/apostrophe"
    },
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/releases/tag/sanitize-html@2.18.1"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
      "type": "CVSS_V3"
    }
  ],
  "summary": "ApostropheCMS sanitize-html: unbounded recursion when re-sanitizing the fallback of a discarded iframe (stack overflow, nestingLimit not enforced)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.