Skip to content

Commit a7048d2

Browse files
authored
fix: sanitize Promise callback arguments to prevent sandbox escape
Add sanitization for both onFulfilled and onRejected callbacks in globalPromise.prototype.then and globalPromise.prototype.catch. Previously, host errors (e.g., from stack trace computation when Error.name is a Symbol) could reach Promise callbacks unsanitized, allowing access to host constructors and sandbox escape.
1 parent 2ebf2a0 commit a7048d2

File tree

4 files changed

+171
-72
lines changed

4 files changed

+171
-72
lines changed

lib/setup-sandbox.js

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,39 @@ const resetPromiseSpecies = (p) => {
3939
};
4040

4141
const globalPromiseThen = globalPromise.prototype.then;
42+
const globalPromiseCatch = globalPromise.prototype.catch;
43+
4244
globalPromise.prototype.then = function then(onFulfilled, onRejected) {
4345
resetPromiseSpecies(this);
46+
if (typeof onFulfilled === 'function') {
47+
const origOnFulfilled = onFulfilled;
48+
onFulfilled = function onFulfilled(value) {
49+
value = ensureThis(value);
50+
return apply(origOnFulfilled, this, [value]);
51+
};
52+
}
53+
if (typeof onRejected === 'function') {
54+
const origOnRejected = onRejected;
55+
onRejected = function onRejected(error) {
56+
error = ensureThis(error);
57+
return apply(origOnRejected, this, [error]);
58+
};
59+
}
4460
return globalPromiseThen.call(this, onFulfilled, onRejected);
4561
};
4662

63+
globalPromise.prototype.catch = function _catch(onRejected) {
64+
resetPromiseSpecies(this);
65+
if (typeof onRejected === 'function') {
66+
const origOnRejected = onRejected;
67+
onRejected = function onRejected(error) {
68+
error = ensureThis(error);
69+
return apply(origOnRejected, this, [error]);
70+
};
71+
}
72+
return globalPromiseCatch.call(this, onRejected);
73+
};
74+
4775
const localReflectApply = (target, thisArg, args) => {
4876
resetPromiseSpecies(thisArg);
4977
return apply(target, thisArg, args);
@@ -537,10 +565,35 @@ if (localPromise) {
537565
overrideWithProxy(PromisePrototype, 'then', PromisePrototype.then, {
538566
__proto__: null,
539567
apply(target, thiz, args) {
568+
if (args.length > 0) {
569+
const onFulfilled = args[0];
570+
if (typeof onFulfilled === 'function') {
571+
args[0] = function sanitizedOnFulfilled(value) {
572+
value = ensureThis(value);
573+
return localReflectApply(onFulfilled, this, [value]);
574+
};
575+
}
576+
}
540577
if (args.length > 1) {
541578
const onRejected = args[1];
542579
if (typeof onRejected === 'function') {
543-
args[1] = function wrapper(error) {
580+
args[1] = function sanitizedOnRejected(error) {
581+
error = ensureThis(error);
582+
return localReflectApply(onRejected, this, [error]);
583+
};
584+
}
585+
}
586+
return localReflectApply(target, thiz, args);
587+
}
588+
});
589+
590+
overrideWithProxy(PromisePrototype, 'catch', PromisePrototype.catch, {
591+
__proto__: null,
592+
apply(target, thiz, args) {
593+
if (args.length > 0) {
594+
const onRejected = args[0];
595+
if (typeof onRejected === 'function') {
596+
args[0] = function sanitizedOnRejected(error) {
544597
error = ensureThis(error);
545598
return localReflectApply(onRejected, this, [error]);
546599
};

package-lock.json

Lines changed: 89 additions & 70 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"alcatraz",
1414
"contextify"
1515
],
16-
"version": "3.10.0",
16+
"version": "3.10.1",
1717
"main": "index.js",
1818
"sideEffects": false,
1919
"repository": "github:patriksimek/vm2",

test/vm.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1267,6 +1267,33 @@ describe('VM', () => {
12671267
`), /constructor is not a function/);
12681268
});
12691269

1270+
it('Promise.prototype.then/catch callback sanitization bypass', async () => {
1271+
const vm2 = new VM();
1272+
// This attack uses an Error with a Symbol name to trigger a host error
1273+
// during stack trace computation, then tries to access host constructors
1274+
// through the unsanitized error in the Promise catch callback.
1275+
// If the error is not sanitized, e.constructor.constructor would be the
1276+
// host's Function which can access 'process'. If sanitized, it's the
1277+
// sandbox's Function where 'process' is not defined.
1278+
await assert.rejects(() => vm2.run(`
1279+
new Promise((resolve, reject) => {
1280+
const error = new Error();
1281+
error.name = Symbol();
1282+
const f = async () => error.stack;
1283+
f().catch(e => {
1284+
try {
1285+
const Error = e.constructor;
1286+
const Function = Error.constructor;
1287+
const p = Function('return process')();
1288+
resolve(p);
1289+
} catch (err) {
1290+
reject(err);
1291+
}
1292+
});
1293+
});
1294+
`), /process is not defined/);
1295+
});
1296+
12701297
after(() => {
12711298
vm = null;
12721299
});

0 commit comments

Comments
 (0)