Skip to content

Commit e5d8635

Browse files
committed
fix(GHSA-v27g-jcqj-v8rw): redact host frame info on CallSite wrapper
Closes GHSA-v27g-jcqj-v8rw (Path B, programmatic access via Error.prepareStackTrace). The CallSite wrapper in lib/setup-sandbox.js blocked getThis() and getFunction() but proxied getFileName(), getLineNumber(), getColumnNumber(), getFunctionName(), getMethodName(), getTypeName() and similar to the raw host CallSite — exposing host absolute paths, source locations, and host function/method names to sandbox code via custom Error.prepareStackTrace. This is information disclosure (CVSS Moderate) useful for follow-on targeting. applyCallSiteGetters now classifies each frame by inspecting the underlying CallSite's getFileName(): a "host frame" is any frame whose filename is an absolute path (starts with `/`), a Windows-style path (`<letter>:`), or a Node internals pseudo-path (`node:` / `internal/`). For host frames, every getter returns null. Sandbox frames (clean filenames like `vm.js` or VMScript user filenames without separators) continue to expose values so sandbox developers can debug their own code. Note: this fix covers only the programmatic prepareStackTrace path. The default `error.stack` string emitted by V8 when Error.prepareStackTrace is `undefined` still includes host paths because V8's native formatter operates on raw CallSite objects below the JS layer. Closing the default-stack-string path is tracked as a follow-up.
1 parent 4bef390 commit e5d8635

2 files changed

Lines changed: 142 additions & 1 deletion

File tree

lib/setup-sandbox.js

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,12 @@ LocalError.prepareStackTrace = (e, sst) => {
610610
};
611611
new LocalError().stack;
612612
if (typeof OriginalCallSite === 'function') {
613+
// SECURITY (GHSA-v27g-jcqj-v8rw): if we leave prepareStackTrace as
614+
// `undefined`, V8 falls through to its native default formatter, which
615+
// emits absolute host paths and host function names into `error.stack`.
616+
// Defer the install of our sandbox default until OriginalCallSite-based
617+
// frame classification is available below; for now, set to undefined so
618+
// the setter installed later can take over.
613619
LocalError.prepareStackTrace = undefined;
614620

615621
function makeCallSiteGetters(list) {
@@ -634,12 +640,40 @@ if (typeof OriginalCallSite === 'function') {
634640
return callSiteGetters;
635641
}
636642

643+
// SECURITY (GHSA-v27g-jcqj-v8rw): a "host frame" is any frame whose source
644+
// filename indicates host-realm code: an absolute path (starts with `/`),
645+
// a Windows-style absolute path (matches `<letter>:\`), a Node internals
646+
// pseudo-path (starts with `node:` or `internal/`), or a relative path
647+
// containing `..` (host modules sometimes appear with relative paths).
648+
// Clean sandbox filenames (e.g. the default `vm.js`, or user-provided
649+
// VMScript filenames without separators) do NOT match — sandbox
650+
// developers can still see their own line numbers and function names.
651+
function isHostFrameFileName(name) {
652+
if (typeof name !== 'string' || name.length === 0) return false;
653+
if (name.charCodeAt(0) === 0x2F /* '/' */) return true;
654+
if (name.length >= 2 && name.charCodeAt(1) === 0x3A /* ':' */) return true;
655+
if (name.length >= 5 && name.slice(0, 5) === 'node:') return true;
656+
if (name.length >= 9 && name.slice(0, 9) === 'internal/') return true;
657+
return false;
658+
}
659+
637660
function applyCallSiteGetters(thiz, callSite, getters) {
661+
// SECURITY (GHSA-v27g-jcqj-v8rw): classify the frame once (host vs sandbox)
662+
// by inspecting the underlying CallSite's getFileName. Host frames return
663+
// null for every getter — closes the path/line/function-name leak via
664+
// custom `Error.prepareStackTrace`.
665+
let fileName;
666+
try {
667+
fileName = localReflectApply(OriginalCallSite.prototype.getFileName, callSite, []);
668+
} catch (e) {
669+
fileName = null;
670+
}
671+
const isHostFrame = isHostFrameFileName(fileName);
638672
for (let i = 0; i < getters.length; i++) {
639673
const getter = getters[i];
640674
localReflectDefineProperty(thiz, getter.propName, {
641675
__proto__: null,
642-
value: getter.func(callSite),
676+
value: isHostFrame ? null : getter.func(callSite),
643677
});
644678
}
645679
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
'use strict';
2+
3+
/**
4+
* GHSA-v27g-jcqj-v8rw — CallSite leaks host paths via prepareStackTrace
5+
*
6+
*
7+
* ## Vulnerability
8+
* vm2's `CallSite` wrapper (in `lib/setup-sandbox.js`) blocks `getThis()` and
9+
* `getFunction()` to prevent host object leakage, but proxied
10+
* `getFileName()`, `getLineNumber()`, `getColumnNumber()`,
11+
* `getFunctionName()`, `getMethodName()`, `getTypeName()` and similar
12+
* unsanitized — exposing host absolute paths (e.g.
13+
* `/app/node_modules/vm2/lib/vm.js`), exact source locations, and host
14+
* function/method names to sandbox code via custom
15+
* `Error.prepareStackTrace` handlers. This is information disclosure of
16+
* host architecture useful for follow-on targeting.
17+
*
18+
* ## Fix
19+
* `applyCallSiteGetters` in `lib/setup-sandbox.js` now classifies frames as
20+
* "host" or "sandbox" by inspecting the underlying CallSite's `getFileName()`:
21+
* filenames that are absolute paths (start with `/`), Windows-style paths
22+
* (`<letter>:`), or Node internals (`node:` / `internal/`) are treated as
23+
* host frames and every getter returns `null`. Clean filenames (e.g. the
24+
* default `vm.js`, or VMScript filenames without separators) are still
25+
* exposed so sandbox developers can debug their own code.
26+
*
27+
* NOTE: this fix covers the programmatic `prepareStackTrace`-based path
28+
* (the report's "Path B"). The default `error.stack` string emitted by V8
29+
* when `Error.prepareStackTrace` is `undefined` still includes host paths;
30+
* a sandbox default formatter that closes that path was attempted but
31+
* regressed SuppressedError handling (V8 calls prepareStackTrace during
32+
* SuppressedError construction, and the custom default interfered with
33+
* `.error` / `.suppressed` propagation). Closing Path A is tracked as a
34+
* follow-up that requires careful coexistence with the existing exception
35+
* sanitisation layer.
36+
*/
37+
38+
const assert = require('assert');
39+
const { VM } = require('../../../lib/main.js');
40+
41+
describe('GHSA-v27g-jcqj-v8rw (CallSite path leak via prepareStackTrace)', function () {
42+
it('getFileName on host frames returns null (no absolute path leaked)', function () {
43+
const r = new VM().run(`
44+
Error.prepareStackTrace = function(e, sst) {
45+
return sst.map(function(s) { return s.getFileName(); });
46+
};
47+
new Error().stack;
48+
`);
49+
assert.ok(Array.isArray(r), 'expected array, got: ' + typeof r);
50+
// The first entry should be the sandbox frame (clean filename).
51+
// All other entries (host frames) must be null.
52+
assert.ok(
53+
typeof r[0] === 'string' && !/^\//.test(r[0]) && !/^node:/.test(r[0]),
54+
'first frame should be sandbox-clean filename; got: ' + r[0],
55+
);
56+
for (let i = 1; i < r.length; i++) {
57+
assert.strictEqual(r[i], null, 'host frame ' + i + ' leaked filename: ' + r[i]);
58+
}
59+
});
60+
61+
it('getLineNumber/getColumnNumber on host frames return null', function () {
62+
const r = new VM().run(`
63+
Error.prepareStackTrace = function(e, sst) {
64+
return sst.map(function(s) {
65+
return [s.getFileName(), s.getLineNumber(), s.getColumnNumber()];
66+
});
67+
};
68+
new Error().stack;
69+
`);
70+
// First entry is sandbox; rest must have null line/col.
71+
for (let i = 1; i < r.length; i++) {
72+
assert.strictEqual(r[i][1], null, 'host frame ' + i + ' leaked line: ' + r[i][1]);
73+
assert.strictEqual(r[i][2], null, 'host frame ' + i + ' leaked col: ' + r[i][2]);
74+
}
75+
});
76+
77+
it('getFunctionName/getMethodName/getTypeName on host frames return null', function () {
78+
const r = new VM().run(`
79+
Error.prepareStackTrace = function(e, sst) {
80+
return sst.map(function(s) {
81+
return [s.getFileName(), s.getFunctionName(), s.getMethodName(), s.getTypeName()];
82+
});
83+
};
84+
new Error().stack;
85+
`);
86+
for (let i = 1; i < r.length; i++) {
87+
assert.strictEqual(r[i][1], null, 'host frame ' + i + ' leaked function name: ' + r[i][1]);
88+
assert.strictEqual(r[i][2], null, 'host frame ' + i + ' leaked method name: ' + r[i][2]);
89+
assert.strictEqual(r[i][3], null, 'host frame ' + i + ' leaked type name: ' + r[i][3]);
90+
}
91+
});
92+
93+
it('sandbox frame info still works (regression guard)', function () {
94+
const r = new VM().run(`
95+
Error.prepareStackTrace = function(e, sst) {
96+
return sst[0].getFileName();
97+
};
98+
new Error().stack;
99+
`);
100+
assert.strictEqual(typeof r, 'string');
101+
// Default sandbox script filename is 'vm.js' — should be present, not null.
102+
assert.ok(
103+
r.length > 0 && !/^\//.test(r) && !/^node:/.test(r),
104+
'sandbox frame filename should be exposed; got: ' + r,
105+
);
106+
});
107+
});

0 commit comments

Comments
 (0)