{"id":"GHSA-9j65-hcg3-gc64","summary":"toml-node: Quadratic-complexity DoS via long dotted keys or table paths in toml.parse()","details":"## Summary\n\n`toml.parse()` is vulnerable to a quadratic-complexity (O(n²)) denial-of-service via a single\nlong dotted key, table header, or inline-table key. A ~100KB crafted document causes a\nmulti-second synchronous hang; a ~200KB document crashes the Node.js process outright with an\nuncatchable out-of-memory error. This is a separate, unpatched code path from the previously\nfixed stack-overflow DoS (`GHSA-82x6-q7mm-w9cf`) — it requires no array/inline-table nesting at\nall, and is confirmed unaffected by the `maxDepth` option that fixed that earlier issue.\n\n## Affected component / version\n\n- Package: `toml` (npm), repository `BinaryMuse/toml-node`\n- Functions: `assign()`, `deepRef()`, `setNestedKey()` in `lib/compiler.js`\n- Confirmed present in the audited checkout at commit `1b2ac40` (2026-07-16), one commit past\n  the `v5.0.0` tag (a changelog-only change) — i.e. present in `v5.0.0`, the latest release.\n  Not exhaustively verified against every prior release, but the affected functions are\n  unrelated to `v5.0.0`'s BigInt/Temporal changes (per `CHANGELOG.md`), so the defect likely\n  predates that release as well.\n\n## Details\n\n`toml.parse(input, options)` (`index.js:5-9`) forwards the caller-supplied string to\n`parser.parse()` and then `compiler.compile()` with no length or structural validation.\nInside `lib/compiler.js`, three separate functions each walk a dotted key's segments in a\n`for` loop and, on **every** iteration, recompute the full path-prefix string from scratch:\n\n- `assign()` (lines 69-107, the sink at line 81): `var intermediatePath =\n  makeFullPath(keys.slice(0, i + 1));` — reached for top-level dotted-key assignments like\n  `a.b.c = 1`.\n- `deepRef()` (lines 247-281, the sink at line 252): the identical pattern via\n  `pathKey(keys.slice(0, i + 1))` — reached from table headers `[a.b.c]` and array-of-tables\n  `[[a.b.c]]` via `setPath()`/`addTableArray()`.\n- `setNestedKey()` (lines 184-202, the sink at line 187): the identical pattern — reached from\n  inline-table dotted keys like `t = { a.b.c = 1 }`.\n\n`keys.slice(0, i + 1)` allocates a new array of length `i`; `pathKey()`/`makeFullPath()`\n(lines 311-317) then does `.map(quoteDottedString).join(\".\")`, also O(i). Since this runs on\nevery one of the `n` segments of a dotted key, total work is `O(1 + 2 + ... + n) = O(n²)`, not\n`O(n)`. The string is computed unconditionally on the happy path (only ever *used* if an error\nis about to be thrown), so there is no early exit that makes this cheap in practice.\n\nNo limit exists anywhere on dotted-key segment count: `dot_ended_table_key_part+` and\n`inline_dot_key_part+` in `src/toml.pegjs` (lines 122, 311) are unbounded `+` repetitions,\nparsed iteratively rather than through the grammar's `value` rule — so the existing\n`MAX_DEPTH`/`maxDepth` nesting guard (`src/toml.pegjs:141-151`, enforced in the generated\n`lib/parser.js:1022`) does not apply to key length at all. We confirmed this directly: setting\n`options.maxDepth: 1` (the strictest possible value) against a 16,000-segment key produced no\nmeasurable timing difference from the unrestricted case.\n\n## Proof of Concept\n\n```js\nconst toml = require('toml');\n\n// n-segment dotted key: \"k0.k1.k2. ... .k{n-1} = 1\"\nfunction segKey(n) {\n  const parts = [];\n  for (let i = 0; i \u003c n; i++) parts.push('k' + i);\n  return parts.join('.') + ' = 1\\n';\n}\n\n// ~100.9 KB input -- takes ~6.6-6.8 seconds\ntoml.parse(segKey(16000));\n\n// ~200 KB input -- crashes the Node process:\n// \"FATAL ERROR: Ineffective mark-compacts near heap limit - JavaScript heap out of memory\"\ntoml.parse(segKey(32000));\n```\n\nMeasured timings (Node v22, direct execution against this repo):\n\n| n (segments) | input size | time |\n|---|---|---|\n| 1,000 | ~4.9 KB | 34 ms |\n| 8,000 | ~46.9 KB | 1.6 s |\n| 16,000 | ~100.9 KB | 6.8 s |\n| 32,000 | ~200 KB | **process crash (OOM)** |\n\nDoubling `n` from 8,000→16,000 roughly quadruples runtime (~4.2x), the signature of O(n²)\nrather than the expected O(n) (~2x). Independently reproduced with the identical scaling\nsignature for the table-header variant (`[k0. ... .k15999]`, exercising `deepRef()`) and the\ninline-table variant (`t = { k0. ... .k15999 = 1 }`, exercising `setNestedKey()`).\n\nWe also generalized this into a property-based fuzzing harness (fast-check, run under Node)\nthat automatically tests the \"doubling input size should not more than roughly double parse\ntime\" invariant across 12 structural dimensions of the grammar/compiler; it independently\nre-flags all three code paths above on every run, with no additional dimension confirmed\nvulnerable after follow-up investigation. The harness is available on request.\n\n## Impact\n\nNode.js is single-threaded — a synchronous multi-second-to-crashing `parse()` call blocks the\nentire event loop of the process calling it, stalling every other request/operation that\nprocess is handling, not just the attacker's own. At the upper end this is not merely a hang:\nan uncaught, process-terminating out-of-memory crash from a ~200KB attacker-supplied string is\na full denial of service requiring no authentication — just the ability to get one string\nparsed by an application using this library (config upload, API request body, imported\nmanifest, or any of this library's intended use cases).\n\n## Suggested remediation\n\nBuild the path prefix as an incrementally-extended string as the loop walks each segment\n(append one already-quoted segment per iteration to a running string) instead of re-slicing an\narray and re-joining the entire prefix from scratch on every iteration. For example, in\n`assign()`:\n\n```js\nfunction assign(node) {\n  var keys = node.key;\n  var value = node.value;\n  var off = node.offset;\n  if (!Array.isArray(keys)) keys = [keys];\n  var reduced = reduceValueNode(value);\n\n  var target = context;\n  var pathStr = pathKey(currentPath); // computed once, not per segment\n  for (var i = 0; i \u003c keys.length - 1; i++) {\n    var k = keys[i];\n    var quotedK = quoteDottedString(k);\n    var intermediatePath = pathStr ? pathStr + \".\" + quotedK : quotedK; // O(new segment), not O(i)\n    pathStr = intermediatePath;\n    // ...same conditional logic as today, using intermediatePath\n    target = target[k];\n  }\n  // ...\n}\n```\n\nThe identical treatment applies to `deepRef()` and `setNestedKey()`. As defense-in-depth,\nconsider also capping total key-segment count or overall input size, the same way `maxDepth`\nalready caps nesting.\n\n\n## Credit\n\nDiscovered and reported by SecDim Security Research\nsecdim.com | @secdim | security@secdim.com","aliases":["CVE-2026-114143"],"modified":"2026-10-22T16:45:03.729593661Z","published":"2026-10-22T16:38:06Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-10-22T16:38:06Z","nvd_published_at":"2026-10-21T21:17:56Z","cwe_ids":["CWE-407"],"severity":"HIGH"},"references":[{"type":"WEB","url":"https://github.com/BinaryMuse/toml-node/security/advisories/GHSA-9j65-hcg3-gc64"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-114143"},{"type":"WEB","url":"https://github.com/BinaryMuse/toml-node/commit/609c873fffea0a27ebe5b4dd098eed2b3daf3606"},{"type":"PACKAGE","url":"https://github.com/BinaryMuse/toml-node"},{"type":"WEB","url":"https://github.com/BinaryMuse/toml-node/releases/tag/v5.0.1"}],"affected":[{"package":{"name":"toml","ecosystem":"npm","purl":"pkg:npm/toml"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"5.0.1"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-9j65-hcg3-gc64/GHSA-9j65-hcg3-gc64.json","last_known_affected_version_range":"\u003c= 5.0.0"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N"}]}