SandboxJS timers have an execution-quota bypass (cross-sandbox currentTicks race)
Description
SandboxJS is a JavaScript sandboxing library. Prior to 0.8.35, SandboxJS timers have an execution-quota bypass. A global tick state (currentTicks.current) is shared between sandboxes. Timer string handlers are compiled at execution time using that global tick state rather than the scheduling sandbox's tick object. In multi-tenant / concurrent sandbox scenarios, another sandbox can overwrite currentTicks.current between scheduling and execution, causing the timer callback to run under a different sandbox's tick budget and bypass the original sandbox's execution quota/watchdog. Version 0.8.35 fixes this issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@nyariv/sandboxjsnpm | < 0.8.35 | 0.8.35 |
Affected products
1Patches
16 files changed · +63 −21
.npmignore+2 −1 modified@@ -14,4 +14,5 @@ rollup.config.mjs .prettierrc jest.config.js rollup.config.js -tsconfig.jest.json \ No newline at end of file +tsconfig.jest.json +package-lock.json
package.json+1 −1 modified@@ -1,6 +1,6 @@ { "name": "@nyariv/sandboxjs", - "version": "0.8.34", + "version": "0.8.35", "description": "Javascript sandboxing library.", "main": "dist/node/Sandbox.js", "module": "./build/Sandbox.js",
package-lock.json+20 −10 modified@@ -1,12 +1,12 @@ { "name": "@nyariv/sandboxjs", - "version": "0.8.33", + "version": "0.8.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nyariv/sandboxjs", - "version": "0.8.33", + "version": "0.8.35", "license": "MIT", "devDependencies": { "@rollup/plugin-node-resolve": "^16.0.3", @@ -60,6 +60,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2130,6 +2131,7 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -2610,6 +2612,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2628,9 +2631,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2882,6 +2885,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3402,6 +3406,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3888,9 +3893,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -4416,6 +4421,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -5235,7 +5241,8 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.memoize": { "version": "4.1.2", @@ -6102,6 +6109,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6882,7 +6890,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/type-check": { "version": "0.4.0", @@ -6926,6 +6935,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver"
src/eval.ts+6 −6 modified@@ -1,4 +1,4 @@ -import { createFunction, createFunctionAsync, currentTicks } from './executor.js'; +import { createFunction, createFunctionAsync } from './executor.js'; import parse, { Lisp, lispifyFunction } from './parser.js'; import { IExecContext, LispType, Ticks } from './utils.js'; @@ -41,7 +41,7 @@ export function createEvalContext(): IEvalContext { } function SB() {} -export function sandboxFunction(context: IExecContext, ticks?: Ticks): SandboxFunction { +export function sandboxFunction(context: IExecContext): SandboxFunction { SandboxFunction.prototype = SB.prototype; return SandboxFunction; function SandboxFunction(...params: string[]) { @@ -50,7 +50,7 @@ export function sandboxFunction(context: IExecContext, ticks?: Ticks): SandboxFu return createFunction( params, parsed.tree, - ticks || currentTicks.current, + context.ctx.ticks, { ...context, constants: parsed.constants, @@ -64,7 +64,7 @@ export function sandboxFunction(context: IExecContext, ticks?: Ticks): SandboxFu export type SandboxAsyncFunction = (code: string, ...args: string[]) => () => Promise<unknown>; function SAF() {} -export function sandboxAsyncFunction(context: IExecContext, ticks?: Ticks): SandboxAsyncFunction { +export function sandboxAsyncFunction(context: IExecContext): SandboxAsyncFunction { SandboxAsyncFunction.prototype = SAF.prototype; return SandboxAsyncFunction; function SandboxAsyncFunction(...params: string[]) { @@ -73,7 +73,7 @@ export function sandboxAsyncFunction(context: IExecContext, ticks?: Ticks): Sand return createFunctionAsync( params, parsed.tree, - ticks || currentTicks.current, + context.ctx.ticks, { ...context, constants: parsed.constants, @@ -97,7 +97,7 @@ export function sandboxedEval(func: SandboxFunction, context: IExecContext): San return createFunction( [], tree, - currentTicks.current, + context.ctx.ticks, { ...context, constants: parsed.constants,
src/executor.ts+0 −3 modified@@ -1697,8 +1697,6 @@ const unexecTypes = new Set([ LispType.Typeof, ]); -export const currentTicks = { current: { ticks: BigInt(0) } as Ticks }; - function _execNoneRecurse<T = any>( ticks: Ticks, tree: LispItem, @@ -1709,7 +1707,6 @@ function _execNoneRecurse<T = any>( inLoopOrSwitch?: string, ): boolean { const exec = isAsync ? execAsync : execSync; - currentTicks.current = ticks; if (tree instanceof Prop) { done(undefined, tree.get(context)); } else if (tree === optional) {
test/ticksQuotaHalt.spec.ts+34 −0 modified@@ -58,4 +58,38 @@ describe('ticks quota halt', () => { expect(haltCalled).toBe(true); expect(len).toBe(100); }); + + it('should halt sandbox A when execution quota is exceeded', (done) => { + const globals = { ...Sandbox.SAFE_GLOBALS, setTimeout, clearTimeout }; + const prototypeWhitelist = Sandbox.SAFE_PROTOTYPES; + + const sandboxA = new Sandbox({ + globals, + prototypeWhitelist, + executionQuota: 50n, + haltOnSandboxError: true, + }); + let haltedA = false; + sandboxA.subscribeHalt(() => { + haltedA = true; + }); + + const sandboxB = new Sandbox({ globals, prototypeWhitelist }); + + // Sandbox A schedules a heavy string handler + sandboxA + .compile( + 'setTimeout("let x=0; for (let i=0;i<200;i++){ x += i } globalThis.doneA = true;", 0);', + )() + .run(); + + // Run sandbox B before A's timer fires + sandboxB.compile('1+1')().run(); + + setTimeout(() => { + expect(haltedA).toBe(true); + expect(sandboxA.context.sandboxGlobal.doneA).toBeUndefined(); + done(); + }, 50); + }); });
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/advisories/GHSA-7p5m-xrh7-769rghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-32723ghsaADVISORY
- github.com/nyariv/SandboxJS/commit/cc8f20b4928afed5478d5ad3d1737ef2dcfaac29ghsax_refsource_MISCWEB
- github.com/nyariv/SandboxJS/security/advisories/GHSA-7p5m-xrh7-769rghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.