Summary
load() parses untrusted TOML using a recursive-descent parser (Chevrotain) and a
recursive tree-walking interpreter. Neither the parser nor the interpreter enforces a
maximum nesting / key depth. A small, attacker-controlled document with deep nesting
(arrays, inline tables) or a long dotted key drives recursion past the V8 call-stack
limit, and load() throws an uncaught RangeError: Maximum call stack size exceeded.
This is a problem for two reasons:
-
Denial of Service. A tiny payload (≈2 KB) reliably aborts parsing. In any
service that parses user-supplied TOML, an unhandled RangeError can crash the
worker/process (500 / restart loop).
-
Error-contract violation. The library signals invalid input via
SyntaxParseError (see src/load/exception.ts; the entire test suite asserts
SyntaxParseError). Consumers that follow this contract —
try { load(input); }
catch (e) {
if (e instanceof SyntaxParseError) return badRequest();
throw e; // unexpected -> propagates
}
— will re-throw the RangeError instead of treating the input as malformed,
turning malformed input into an unexpected fatal error.
Impact
- Unauthenticated attacker who can supply a TOML string to an application using
js-toml can force load() to throw a non-SyntaxParseError error.
- If the error is not caught by a generic handler, the Node.js process terminates
(availability impact).
- Even when caught generically, the documented/expected error type contract is broken,
which can bypass input-validation branches that key off SyntaxParseError.
No confidentiality or integrity impact. The error is thrown synchronously and is
catchable, so a consumer that catches all exceptions is not crashed — this is why the
severity is assessed Low–Medium rather than High.
Affected code / root cause
There is no depth bound anywhere in the parse pipeline. Recursion occurs in (at least)
two independent places:
- Parser (recursive descent).
src/load/parser.ts — array / inlineTable
rules recurse through value for each nesting level.
- Interpreter (tree walk).
src/load/interpreter.ts —
assignValue, createTable, getOrCreateArray recurse per dotted-key segment, and
cleanInternalProperties recurses per object depth.
Because there is no configurable or hard limit, the recursion depth is bounded only by
the V8 stack, and overflow surfaces as a raw RangeError.
Steps to reproduce (PoC)
Environment used: Node.js v20, js-toml@1.1.2.
import { load, SyntaxParseError } from 'js-toml';
function probe(label, toml) {
try {
load(toml);
console.log(`[OK] ${label}`);
} catch (e) {
const expected = e instanceof SyntaxParseError;
console.log(`[${e.constructor.name}${expected ? '' : ' (UNEXPECTED)'}] ${label}: ${String(e.message).split('\n')[0]}`);
}
}
// 1) Deeply nested arrays (~2 KB payload)
probe('nested array depth 1000', 'x = ' + '['.repeat(1000) + ']'.repeat(1000));
// 2) Deeply nested inline tables
let v = '1';
for (let i = 0; i < 1000; i++) v = `{ a = ${v} }`;
probe('nested inline table depth 1000', 'x = ' + v);
// 3) Deep dotted key (interpreter recursion)
const key = Array.from({ length: 5000 }, (_, i) => 'a' + i).join('.');
probe('dotted key depth 5000', `${key} = 1`);
Observed output
[RangeError (UNEXPECTED)] nested array depth 1000: Maximum call stack size exceeded
[RangeError (UNEXPECTED)] nested inline table depth 1000: Maximum call stack size exceeded
[RangeError (UNEXPECTED)] dotted key depth 5000: Maximum call stack size exceeded
Each case throws RangeError, not SyntaxParseError. Overflow occurs in single-digit
milliseconds, so it is not a CPU-exhaustion issue — it is an immediate, deterministic abort.
Notes on thresholds (machine-dependent; exact numbers vary by stack size):
- Nested arrays / inline tables overflow at roughly depth ~1000 (parser recursion).
- Dotted keys overflow at roughly depth ~5000 (interpreter recursion); depth 1000 still succeeds.
Suggested remediation
Enforce an explicit, documented maximum nesting/key depth and surface violations as
SyntaxParseError (consistent with all other invalid-input handling). For example:
- Track current depth in the parser/interpreter and throw
SyntaxParseError
(e.g. "Maximum nesting depth exceeded") once a configurable limit (e.g. 100–1000)
is reached, before the native stack overflows.
- As a defensive backstop, wrap the top-level
load() body so that a thrown
RangeError from stack exhaustion is converted into a SyntaxParseError, guaranteeing
the documented error contract holds for all malformed input.
A configurable limit (with a safe default) is preferable so legitimate deep documents can
opt into a higher bound.
Disclosure
Reported privately via GitHub Security Advisory. Happy to provide additional reproducers
or test against a candidate patch.
Summary
load()parses untrusted TOML using a recursive-descent parser (Chevrotain) and arecursive tree-walking interpreter. Neither the parser nor the interpreter enforces a
maximum nesting / key depth. A small, attacker-controlled document with deep nesting
(arrays, inline tables) or a long dotted key drives recursion past the V8 call-stack
limit, and
load()throws an uncaughtRangeError: Maximum call stack size exceeded.This is a problem for two reasons:
Denial of Service. A tiny payload (≈2 KB) reliably aborts parsing. In any
service that parses user-supplied TOML, an unhandled
RangeErrorcan crash theworker/process (500 / restart loop).
Error-contract violation. The library signals invalid input via
SyntaxParseError(seesrc/load/exception.ts; the entire test suite assertsSyntaxParseError). Consumers that follow this contract —— will re-throw the
RangeErrorinstead of treating the input as malformed,turning malformed input into an unexpected fatal error.
Impact
js-tomlcan forceload()to throw a non-SyntaxParseErrorerror.(availability impact).
which can bypass input-validation branches that key off
SyntaxParseError.No confidentiality or integrity impact. The error is thrown synchronously and is
catchable, so a consumer that catches all exceptions is not crashed — this is why the
severity is assessed Low–Medium rather than High.
Affected code / root cause
There is no depth bound anywhere in the parse pipeline. Recursion occurs in (at least)
two independent places:
src/load/parser.ts—array/inlineTablerules recurse through
valuefor each nesting level.src/load/interpreter.ts—assignValue,createTable,getOrCreateArrayrecurse per dotted-key segment, andcleanInternalPropertiesrecurses per object depth.Because there is no configurable or hard limit, the recursion depth is bounded only by
the V8 stack, and overflow surfaces as a raw
RangeError.Steps to reproduce (PoC)
Environment used: Node.js v20,
js-toml@1.1.2.Observed output
Each case throws
RangeError, notSyntaxParseError. Overflow occurs in single-digitmilliseconds, so it is not a CPU-exhaustion issue — it is an immediate, deterministic abort.
Notes on thresholds (machine-dependent; exact numbers vary by stack size):
Suggested remediation
Enforce an explicit, documented maximum nesting/key depth and surface violations as
SyntaxParseError(consistent with all other invalid-input handling). For example:SyntaxParseError(e.g.
"Maximum nesting depth exceeded") once a configurable limit (e.g. 100–1000)is reached, before the native stack overflows.
load()body so that a thrownRangeErrorfrom stack exhaustion is converted into aSyntaxParseError, guaranteeingthe documented error contract holds for all malformed input.
A configurable limit (with a safe default) is preferable so legitimate deep documents can
opt into a higher bound.
Disclosure
Reported privately via GitHub Security Advisory. Happy to provide additional reproducers
or test against a candidate patch.