CVE-2026-34217
Description
SandboxJS is a JavaScript sandboxing library. Prior to 0.8.36, a scope modification vulnerability exists in @nyariv/sandboxjs. The vulnerability allows untrusted sandboxed code to leak internal interpreter objects through the new operator, exposing sandbox scope objects in the scope hierarchy to untrusted code; an unexpected and undesired exploit. While this could allow modifying scopes inside the sandbox, code evaluation remains sandboxed and prototypes remain protected throughout the execution. This vulnerability is fixed in 0.8.36.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@nyariv/sandboxjsnpm | < 0.8.36 | 0.8.36 |
Affected products
1Patches
19 files changed · +212 −76
package.json+1 −1 modified@@ -1,6 +1,6 @@ { "name": "@nyariv/sandboxjs", - "version": "0.8.35", + "version": "0.8.36", "description": "Javascript sandboxing library.", "main": "dist/node/Sandbox.js", "module": "./build/Sandbox.js",
src/eval.ts+3 −3 modified@@ -46,7 +46,7 @@ export function sandboxFunction(context: IExecContext): SandboxFunction { return SandboxFunction; function SandboxFunction(...params: string[]) { const code = params.pop() || ''; - const parsed = parse(code); + const parsed = parse(code, false, false, context.ctx.options.maxParserRecursionDepth); return createFunction( params, parsed.tree, @@ -69,7 +69,7 @@ export function sandboxAsyncFunction(context: IExecContext): SandboxAsyncFunctio return SandboxAsyncFunction; function SandboxAsyncFunction(...params: string[]) { const code = params.pop() || ''; - const parsed = parse(code); + const parsed = parse(code, false, false, context.ctx.options.maxParserRecursionDepth); return createFunctionAsync( params, parsed.tree, @@ -91,7 +91,7 @@ export function sandboxedEval(func: SandboxFunction, context: IExecContext): San return sandboxEval; function sandboxEval(code: string) { // Parse the code and wrap last statement in return for completion value - const parsed = parse(code); + const parsed = parse(code, false, false, context.ctx.options.maxParserRecursionDepth); const tree = wrapLastStatementInReturn(parsed.tree); // Create and execute function with modified tree return createFunction(
src/executor.ts+41 −41 modified@@ -470,26 +470,6 @@ function getGlobalProp(val: unknown, context: IExecContext, prop?: Prop) { } } -function sanitizeArray<T>(val: T, context: IExecContext, cache = new WeakSet<object>()): T { - if (!Array.isArray(val)) return val; - if (cache.has(val)) return val; - cache.add(val); - for (let i = 0; i < val.length; i++) { - const item = val[i]; - if (item === globalThis) { - val[i] = context.ctx.sandboxGlobal; - } else if (typeof item === 'function') { - const replacement = context.evals.get(item); - if (replacement) { - val[i] = replacement; - } - } else { - sanitizeArray(item, context, cache); - } - } - return val; -} - addOps<unknown, Lisp[], any>(LispType.Call, ({ done, a, b, obj, context }) => { if (context.ctx.options.forbidFunctionCalls) throw new SandboxCapabilityError('Function invocations are not allowed'); @@ -507,13 +487,12 @@ addOps<unknown, Lisp[], any>(LispType.Call, ({ done, a, b, obj, context }) => { } }) .flat() - .map((item) => valueOrProp(item, context)); + .map((item) => sanitizeProp(item, context)); if (typeof obj === 'function') { const evl = context.evals.get(obj); let ret = evl ? evl(obj, ...vals) : obj(...vals); - ret = getGlobalProp(ret, context) || ret; - sanitizeArray(ret, context); + ret = sanitizeProp(ret, context); done(undefined, ret); return; } @@ -599,8 +578,7 @@ addOps<unknown, Lisp[], any>(LispType.Call, ({ done, a, b, obj, context }) => { obj.get(context); const evl = context.evals.get(obj.context[obj.prop] as any); let ret = evl ? evl(obj.context[obj.prop], ...vals) : (obj.context[obj.prop](...vals) as unknown); - ret = getGlobalProp(ret, context) || ret; - sanitizeArray(ret, context); + ret = sanitizeProp(ret, context); done(undefined, ret); }); @@ -628,7 +606,7 @@ addOps<unknown, Lisp[]>(LispType.CreateArray, ({ done, b, context }) => { } }) .flat() - .map((item) => valueOrProp(item, context)); + .map((item) => sanitizeProp(item, context)); done(undefined, items); }); @@ -700,7 +678,7 @@ addOps<unknown, string>(LispType.LiteralIndex, ({ exec, done, ticks, b, context, name.replace(/(\\\\)*(\\)?\${(\d+)}/g, (match, $$, $, num) => { if ($) return match; const res = reses[num]; - return ($$ ? $$ : '') + `${valueOrProp(res, context)}`; + return ($$ ? $$ : '') + `${sanitizeProp(res, context)}`; }), ); }); @@ -870,7 +848,7 @@ addOps<number, number>(LispType.BitUnsignedShiftRight, ({ done, a, b }) => ); addOps<unknown, LispItem>(LispType.Typeof, ({ exec, done, ticks, b, context, scope }) => { exec(ticks, b, scope, context, (e, prop) => { - done(undefined, typeof valueOrProp(prop, context)); + done(undefined, typeof sanitizeProp(prop, context)); }); }); @@ -1064,11 +1042,11 @@ addOps<LispItem, LispItem>(LispType.LoopAction, ({ done, a, context, inLoopOrSwi }); addOps<LispItem, If>(LispType.If, ({ exec, done, ticks, a, b, context, scope, inLoopOrSwitch }) => { - exec(ticks, valueOrProp(a, context) ? b.t : b.f, scope, context, done, inLoopOrSwitch); + exec(ticks, sanitizeProp(a, context) ? b.t : b.f, scope, context, done, inLoopOrSwitch); }); addOps<LispItem, If>(LispType.InlineIf, ({ exec, done, ticks, a, b, context, scope }) => { - exec(ticks, valueOrProp(a, context) ? b.t : b.f, scope, context, done, undefined); + exec(ticks, sanitizeProp(a, context) ? b.t : b.f, scope, context, done, undefined); }); addOps<Lisp, Lisp>(LispType.InlineIfCase, ({ done, a, b }) => done(undefined, new If(a, b))); @@ -1081,7 +1059,7 @@ addOps<LispItem, SwitchCase[]>(LispType.Switch, ({ exec, done, ticks, a, b, cont return; } let toTest = args[1]; - toTest = valueOrProp(toTest, context); + toTest = sanitizeProp(toTest, context); if (exec === execSync) { let res: ExecReturn<unknown>; let isTrue = false; @@ -1091,7 +1069,7 @@ addOps<LispItem, SwitchCase[]>(LispType.Switch, ({ exec, done, ticks, a, b, cont (isTrue = !caseItem[1] || toTest === - valueOrProp( + sanitizeProp( syncDone((d) => exec(ticks, caseItem[1], scope, context, d)).result, context, )) @@ -1121,7 +1099,7 @@ addOps<LispItem, SwitchCase[]>(LispType.Switch, ({ exec, done, ticks, a, b, cont (isTrue = !caseItem[1] || toTest === - valueOrProp( + sanitizeProp( (ad = asyncDone((d) => exec(ticks, caseItem[1], scope, context, d))).isInstant === true ? ad.instant @@ -1276,7 +1254,9 @@ addOps<new (...args: unknown[]) => unknown, unknown[]>(LispType.New, ({ done, a, if (!context.ctx.globalsWhitelist.has(a) && !context.ctx.sandboxedFunctions.has(a)) { throw new SandboxAccessError(`Object construction not allowed: ${a.constructor.name}`); } - done(undefined, new a(...b)); + b = b.map((item) => sanitizeProp(item, context)); + const ret = sanitizeProp(new a(...b), context); + done(undefined, ret); }); addOps(LispType.Throw, ({ done, b }) => { @@ -1285,12 +1265,6 @@ addOps(LispType.Throw, ({ done, b }) => { addOps<unknown[]>(LispType.Expression, ({ done, a }) => done(undefined, a.pop())); addOps(LispType.None, ({ done }) => done()); -function valueOrProp(a: unknown, context: IExecContext): unknown { - if (a instanceof Prop) return a.get(context); - if (a === optional) return undefined; - return a; -} - export function execMany( ticks: Ticks, exec: Execution, @@ -1577,6 +1551,32 @@ type OpsCallbackParams<a, b, obj, bobj> = { inLoopOrSwitch?: string; }; +function sanitizeArray<T>(val: T, context: IExecContext, cache = new WeakSet<object>()): T { + if (!Array.isArray(val)) return val; + if (cache.has(val)) return val; + cache.add(val); + for (let i = 0; i < val.length; i++) { + const item = val[i]; + val[i] = sanitizeProp(item, context); + } + return val; +} + +function sanitizeProp(value: unknown, context: IExecContext): unknown { + value = getGlobalProp(value, context) || value; + + if (value instanceof Prop) { + value = value.get(context); + } + + if (value === optional) { + return undefined; + } + + sanitizeArray(value, context); + return value; +} + function checkHaltExpectedTicks( params: OpsCallbackParams<any, any, any, any>, expectTicks = 0, @@ -1734,7 +1734,7 @@ function _execNoneRecurse<T = any>( if (args.length === 1) done(args[0]); else try { - done(undefined, (await valueOrProp(args[1], context)) as any); + done(undefined, (await sanitizeProp(args[1], context)) as any); } catch (err) { done(err); }
src/parser.ts+64 −18 modified@@ -1,5 +1,5 @@ import unraw from './unraw.js'; -import { CodeString, isLisp, LispType, reservedWords } from './utils.js'; +import { CodeString, isLisp, LispType, reservedWords, SandboxCapabilityError } from './utils.js'; export type DefineLisp< op extends LispType, @@ -233,6 +233,7 @@ export interface IConstants { literals: Literal[]; regexes: IRegEx[]; eager: boolean; + maxDepth: number; } export interface IExecutionTree { @@ -448,10 +449,14 @@ export function restOfExp( firstOpening?: string, closingsTests?: RegExp[], details: restDetails = {}, + depth = 0, ): CodeString { if (!part.length) { return part; } + if (depth > constants.maxDepth) { + throw new SandboxCapabilityError('Maximum expression depth exceeded'); + } details.words = details.words || []; let isStart = true; tests = tests || []; @@ -477,7 +482,16 @@ export function restOfExp( let char = part.char(i)!; if (quote === '"' || quote === "'" || quote === '`') { if (quote === '`' && char === '$' && part.char(i + 1) === '{' && !escape) { - const skip = restOfExp(constants, part.substring(i + 2), [], '{'); + const skip = restOfExp( + constants, + part.substring(i + 2), + [], + '{', + undefined, + undefined, + {}, + depth + 1, + ); i += skip.length + 2; } else if (char === quote && !escape) { return part.substring(0, i); @@ -500,7 +514,16 @@ export function restOfExp( done = true; break; } else { - const skip = restOfExp(constants, part.substring(i + 1), [], char); + const skip = restOfExp( + constants, + part.substring(i + 1), + [], + char, + undefined, + undefined, + {}, + depth + 1, + ); cache.set(skip.start - 1, skip.end); i += skip.length + 1; isStart = false; @@ -1582,25 +1605,33 @@ setLispType(['new'] as const, (constants, type, part, res, expect, ctx) => { }); const ofStart2 = lispify( - undefined as any, + { maxDepth: 10 } as any, new CodeString('let $$iterator = $$obj[Symbol.iterator]()'), ['initialize'], ); -const ofStart3 = lispify(undefined as any, new CodeString('let $$next = $$iterator.next()'), [ - 'initialize', -]); -const ofCondition = lispify(undefined as any, new CodeString('return !$$next.done'), [ - 'initialize', -]); -const ofStep = lispify(undefined as any, new CodeString('$$next = $$iterator.next()')); -const inStart2 = lispify(undefined as any, new CodeString('let $$keys = Object.keys($$obj)'), [ +const ofStart3 = lispify( + { maxDepth: 10 } as any, + new CodeString('let $$next = $$iterator.next()'), + ['initialize'], +); +const ofCondition = lispify({ maxDepth: 10 } as any, new CodeString('return !$$next.done'), [ 'initialize', ]); -const inStart3 = lispify(undefined as any, new CodeString('let $$keyIndex = 0'), ['initialize']); -const inStep = lispify(undefined as any, new CodeString('$$keyIndex++')); -const inCondition = lispify(undefined as any, new CodeString('return $$keyIndex < $$keys.length'), [ +const ofStep = lispify({ maxDepth: 10 } as any, new CodeString('$$next = $$iterator.next()')); +const inStart2 = lispify( + { maxDepth: 10 } as any, + new CodeString('let $$keys = Object.keys($$obj)'), + ['initialize'], +); +const inStart3 = lispify({ maxDepth: 10 } as any, new CodeString('let $$keyIndex = 0'), [ 'initialize', ]); +const inStep = lispify({ maxDepth: 10 } as any, new CodeString('$$keyIndex++')); +const inCondition = lispify( + { maxDepth: 10 } as any, + new CodeString('return $$keyIndex < $$keys.length'), + ['initialize'], +); function lispify( constants: IConstants, @@ -1916,7 +1947,11 @@ export function extractConstants( constants: IConstants, str: string, currentEnclosure = '', + depth = 0, ): { str: string; length: number } { + if (depth > constants.maxDepth) { + throw new SandboxCapabilityError('Maximum expression depth exceeded'); + } let quote; let extract: (string | number)[] = []; let escape = false; @@ -1950,7 +1985,7 @@ export function extractConstants( if (quote) { if (quote === '`' && char === '$' && str[i + 1] === '{') { - const skip = extractConstants(constants, str.substring(i + 2), '{'); + const skip = extractConstants(constants, str.substring(i + 2), '{', depth + 1); currJs.push(skip.str); extract.push('${', currJs.length - 1, `}`); i += skip.length + 2; @@ -2020,10 +2055,21 @@ export function extractConstants( return { str: strRes.join(''), length: i }; } -export default function parse(code: string, eager = false, expression = false): IExecutionTree { +export default function parse( + code: string, + eager = false, + expression = false, + maxParserRecursionDepth = 256, +): IExecutionTree { if (typeof code !== 'string') throw new ParseError(`Cannot parse ${code}`, code); let str = ' ' + code; - const constants: IConstants = { strings: [], literals: [], regexes: [], eager }; + const constants: IConstants = { + strings: [], + literals: [], + regexes: [], + eager, + maxDepth: maxParserRecursionDepth, + }; str = extractConstants(constants, str).str; for (const l of constants.literals) {
src/SandboxExec.ts+2 −2 modified@@ -10,7 +10,7 @@ import { IScope, replacementCallback, SandboxExecutionQuotaExceededError, - SandboxGlobal, + sandboxedGlobal, Scope, SubscriptionSubject, Ticks, @@ -105,6 +105,7 @@ export default class SandboxExec { globals: SandboxExec.SAFE_GLOBALS, prototypeWhitelist: SandboxExec.SAFE_PROTOTYPES, prototypeReplacements: new Map<new () => any, replacementCallback>(), + maxParserRecursionDepth: 256, }, options || {}, ); @@ -172,7 +173,6 @@ export default class SandboxExec { static get SAFE_PROTOTYPES(): Map<any, Set<string>> { const protos = [ - SandboxGlobal, Function, Boolean, Number,
src/Sandbox.ts+9 −5 modified@@ -26,7 +26,11 @@ export default class Sandbox extends SandboxExec { audit: true, }); return sandbox.executeTree( - createExecContext(sandbox, parse(code, true), createEvalContext()), + createExecContext( + sandbox, + parse(code, true, false, sandbox.context.options.maxParserRecursionDepth), + createEvalContext(), + ), scopes, ); } @@ -39,7 +43,7 @@ export default class Sandbox extends SandboxExec { code: string, optimize = false, ): (...scopes: IScope[]) => { context: IExecContext; run: () => T } { - const parsed = parse(code, optimize); + const parsed = parse(code, optimize, false, this.context.options.maxParserRecursionDepth); const exec = (...scopes: IScope[]) => { const context = createExecContext(this, parsed, this.evalContext); return { context, run: () => this.executeTree<T>(context, [...scopes]).result }; @@ -51,7 +55,7 @@ export default class Sandbox extends SandboxExec { code: string, optimize = false, ): (...scopes: IScope[]) => { context: IExecContext; run: () => Promise<T> } { - const parsed = parse(code, optimize); + const parsed = parse(code, optimize, false, this.context.options.maxParserRecursionDepth); const exec = (...scopes: IScope[]) => { const context = createExecContext(this, parsed, this.evalContext); return { @@ -66,7 +70,7 @@ export default class Sandbox extends SandboxExec { code: string, optimize = false, ): (...scopes: IScope[]) => { context: IExecContext; run: () => T } { - const parsed = parse(code, optimize, true); + const parsed = parse(code, optimize, true, this.context.options.maxParserRecursionDepth); const exec = (...scopes: IScope[]) => { const context = createExecContext(this, parsed, this.evalContext); return { context, run: () => this.executeTree<T>(context, [...scopes]).result }; @@ -78,7 +82,7 @@ export default class Sandbox extends SandboxExec { code: string, optimize = false, ): (...scopes: IScope[]) => { context: IExecContext; run: () => Promise<T> } { - const parsed = parse(code, optimize, true); + const parsed = parse(code, optimize, true, this.context.options.maxParserRecursionDepth); const exec = (...scopes: IScope[]) => { const context = createExecContext(this, parsed, this.evalContext); return {
src/utils.ts+18 −6 modified@@ -21,6 +21,7 @@ export interface IOptionParams { globals?: IGlobals; executionQuota?: bigint; haltOnSandboxError?: boolean; + maxParserRecursionDepth?: number; } export interface IOptions { @@ -32,6 +33,7 @@ export interface IOptions { globals: IGlobals; executionQuota?: bigint; haltOnSandboxError?: boolean; + maxParserRecursionDepth: number; } export interface IContext { @@ -78,14 +80,19 @@ export interface ISandboxGlobal { [key: string]: unknown; } interface SandboxGlobalConstructor { - new (globals: IGlobals): ISandboxGlobal; + new (): ISandboxGlobal; } -export const SandboxGlobal = function SandboxGlobal(this: ISandboxGlobal, globals: IGlobals) { - for (const i in globals) { - this[i] = globals[i]; +function SandboxGlobal() {} +export function sandboxedGlobal(globals: ISandboxGlobal): SandboxGlobalConstructor { + SG.prototype = SandboxGlobal.prototype; + return SG as unknown as SandboxGlobalConstructor; + function SG(this: ISandboxGlobal) { + for (const i in globals) { + this[i] = globals[i]; + } } -} as any as SandboxGlobalConstructor; +} export type IGlobals = ISandboxGlobal; @@ -116,7 +123,8 @@ export class ExecContext implements IExecContext { } export function createContext(sandbox: SandboxExec, options: IOptions): IContext { - const sandboxGlobal = new SandboxGlobal(options.globals); + const SandboxGlobal = sandboxedGlobal(options.globals); + const sandboxGlobal = new SandboxGlobal(); const context: IContext = { sandbox: sandbox, globalsWhitelist: new Set(Object.values(options.globals)), @@ -127,6 +135,7 @@ export function createContext(sandbox: SandboxExec, options: IOptions): IContext ticks: { ticks: 0n, tickLimit: options.executionQuota }, sandboxedFunctions: new WeakSet<Function>(), }; + context.prototypeWhitelist.set(Object.getPrototypeOf(sandboxGlobal), new Set()); context.prototypeWhitelist.set(Object.getPrototypeOf([][Symbol.iterator]()) as object, new Set()); return context; } @@ -175,6 +184,9 @@ export function createExecContext( for (const [key, value] of evals) { sandbox.context.prototypeWhitelist.set(value.prototype, new Set()); sandbox.context.prototypeWhitelist.set(key.prototype, new Set()); + if (sandbox.context.globalsWhitelist.has(key)) { + sandbox.context.globalsWhitelist.add(value); + } } } return execContext;
test/eval/testCases/other-operators.data.ts+4 −0 modified@@ -54,6 +54,10 @@ export const tests: TestCase[] = [ safeExpect: '1970-01-01T00:00:00.000Z', category: 'Other Operators', }, + { + code: 'function E(a) { this.scope = a.context }; return new E(isNaN).scope?.Function?.name', + category: 'Other Operators', + }, { code: 'typeof 5 + "2"', evalExpect: 'number2',
test/sandboxLimits.spec.ts+70 −0 modified@@ -225,4 +225,74 @@ describe('Executor Edge Cases', () => { expect(haltContext.context).toBeDefined(); }); }); + + describe('maxParserRecursionDepth option', () => { + it('should not throw with default maxParserRecursionDepth and 256 nesting', () => { + const sandbox = new Sandbox(); + const deeplyNested = 'return' + '('.repeat(256) + '1' + ')'.repeat(256); + const fn = sandbox.compile(deeplyNested); + expect(fn({}).run()).toBe(1); + }); + + it('should throw when expression nesting exceeds maxParserRecursionDepth', () => { + const sandbox = new Sandbox({ maxParserRecursionDepth: 5 }); + const deeplyNested = '('.repeat(10) + '1' + ')'.repeat(10); + expect(() => { + sandbox.compile(deeplyNested); + }).toThrow('Maximum expression depth exceeded'); + }); + + it('should not throw when expression nesting is within maxParserRecursionDepth', () => { + const sandbox = new Sandbox({ maxParserRecursionDepth: 20 }); + const nested = 'return (((1 + 2)))'; + const fn = sandbox.compile(nested); + expect(fn({}).run()).toBe(3); + }); + + it('should throw on deeply nested template literals when depth is low', () => { + const sandbox = new Sandbox({ maxParserRecursionDepth: 2 }); + expect(() => { + sandbox.compile('`${ `${ `${ 1 }` }` }`'); + }).toThrow('Maximum expression depth exceeded'); + }); + + it('should parse deeply nested template literals within depth limit', () => { + const sandbox = new Sandbox({ maxParserRecursionDepth: 4 }); + const fn = sandbox.compile('return `${ `${ 1 }` }`'); + expect(fn({}).run()).toBe('1'); + }); + + it('should apply depth limit when using compileExpression', () => { + const sandbox = new Sandbox({ maxParserRecursionDepth: 5 }); + const deeplyNested = '('.repeat(10) + '1' + ')'.repeat(10); + expect(() => { + sandbox.compileExpression(deeplyNested); + }).toThrow('Maximum expression depth exceeded'); + }); + + it('should apply depth limit when using compileExpressionAsync', () => { + const sandbox = new Sandbox({ maxParserRecursionDepth: 5 }); + const deeplyNested = '('.repeat(10) + '1' + ')'.repeat(10); + expect(() => { + sandbox.compileExpressionAsync(deeplyNested); + }).toThrow('Maximum expression depth exceeded'); + }); + + it('should apply depth limit when using compileAsync', () => { + const sandbox = new Sandbox({ maxParserRecursionDepth: 5 }); + const deeplyNested = '('.repeat(10) + '1' + ')'.repeat(10); + expect(() => { + sandbox.compileAsync(deeplyNested); + }).toThrow('Maximum expression depth exceeded'); + }); + + it('should apply depth limit when calling Function constructor', () => { + const sandbox = new Sandbox({ maxParserRecursionDepth: 5 }); + expect(() => { + sandbox + .compile('new Function("return " + "(".repeat(10) + "1" + ")".repeat(10))()')({}) + .run(); + }).toThrow('Maximum expression depth exceeded'); + }); + }); });
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/nyariv/SandboxJS/security/advisories/GHSA-hg73-4w7g-q96wnvdExploitMitigationVendor AdvisoryWEB
- github.com/advisories/GHSA-hg73-4w7g-q96wghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-34217ghsaADVISORY
- github.com/nyariv/SandboxJS/commit/abc02f657279e51a4aaad2bc8f99f3e37a01b287ghsaWEB
News mentions
0No linked articles in our index yet.