Skip to content

Commit 2e4c6bf

Browse files
committed
feat: US-021 - Custom bindings tests
Add comprehensive tests for custom bindings covering round-trip, validation, freezing, and serialization. Fix two bugs discovered during testing: 1. Binding handlers were not included in the _loadPolyfill dispatch handlers map, so sandbox-side dispatch wrappers couldn't reach host-side handlers. 2. The inflation snippet tried to read binding functions from globalThis, but custom __bind.* keys are never installed as V8 native globals. Fixed by building dispatch wrappers directly into the SecureExec.bindings tree.
1 parent 02c97ca commit 2e4c6bf

2 files changed

Lines changed: 320 additions & 2 deletions

File tree

packages/secure-exec-nodejs/src/execution-driver.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,10 @@ export class NodeExecutionDriver implements RuntimeDriver {
481481
onPtySetRawMode: s.onPtySetRawMode,
482482
stdinIsTTY: s.processConfig.stdinIsTTY,
483483
}),
484+
// Custom bindings dispatched through _loadPolyfill
485+
...(this.flattenedBindings ? Object.fromEntries(
486+
this.flattenedBindings.map(b => [b.key, b.handler])
487+
) : {}),
484488
}),
485489
...buildTimerBridgeHandlers({
486490
budgetState: s.budgetState,
@@ -830,21 +834,26 @@ function getOsConfigGlobalKey(): string { return HOST_BRIDGE_GLOBAL_KEYS.osConfi
830834

831835
/** Build the JS snippet that inflates __bind.* globals into a frozen SecureExec.bindings tree. */
832836
function buildBindingsInflationSnippet(bindingKeys: string[]): string {
837+
// Build dispatch wrappers for each binding key and assign directly to the
838+
// tree nodes. Uses _loadPolyfill as the dispatch multiplexer (same as the
839+
// static dispatch shim for internal bridge globals).
833840
return `(function(){
834841
var __bindingKeys__=${JSON.stringify(bindingKeys)};
835842
var tree={};
843+
function makeBindFn(bk){
844+
return function(){var args=Array.prototype.slice.call(arguments);var encoded="__bd:"+bk+":"+JSON.stringify(args);var r=_loadPolyfill.applySyncPromise(undefined,[encoded]);if(r===null)return undefined;try{var p=JSON.parse(r);if(p.__bd_error)throw new Error(p.__bd_error);return p.__bd_result;}catch(e){if(e.message&&e.message.startsWith("No handler:"))return undefined;throw e;}};
845+
}
836846
for(var i=0;i<__bindingKeys__.length;i++){
837847
var parts=__bindingKeys__[i].split(".");
838848
var node=tree;
839849
for(var j=0;j<parts.length-1;j++){node[parts[j]]=node[parts[j]]||{};node=node[parts[j]];}
840-
node[parts[parts.length-1]]=globalThis["__bind."+__bindingKeys__[i]];
850+
node[parts[parts.length-1]]=makeBindFn("__bind."+__bindingKeys__[i]);
841851
}
842852
function deepFreeze(obj){
843853
var vals=Object.values(obj);
844854
for(var k=0;k<vals.length;k++){if(typeof vals[k]==="object"&&vals[k]!==null)deepFreeze(vals[k]);}
845855
return Object.freeze(obj);
846856
}
847857
Object.defineProperty(globalThis,"SecureExec",{value:Object.freeze({bindings:deepFreeze(tree)}),writable:false,enumerable:true,configurable:false});
848-
for(var i=0;i<__bindingKeys__.length;i++){delete globalThis["__bind."+__bindingKeys__[i]];}
849858
})();`;
850859
}
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
/**
2+
* Custom bindings tests — validation, round-trip, freezing, and serialization.
3+
*
4+
* Tests both the pure validation logic (flattenBindingTree) and end-to-end
5+
* sandbox execution with host functions exposed via SecureExec.bindings.
6+
*/
7+
8+
import { afterEach, describe, expect, it } from "vitest";
9+
import {
10+
createNodeDriver,
11+
NodeExecutionDriver,
12+
} from "../../../src/index.js";
13+
import type { BindingTree } from "../../../src/index.js";
14+
import { flattenBindingTree, BINDING_PREFIX } from "@secure-exec/nodejs";
15+
16+
// ---------------------------------------------------------------------------
17+
// Helpers
18+
// ---------------------------------------------------------------------------
19+
20+
function createDriverWithBindings(bindings?: BindingTree): NodeExecutionDriver {
21+
const driver = createNodeDriver();
22+
return new NodeExecutionDriver({
23+
system: driver,
24+
runtime: driver.runtime,
25+
bindings,
26+
});
27+
}
28+
29+
type StdioEvent = { channel: "stdout" | "stderr"; message: string };
30+
31+
function createStdioCapture() {
32+
const events: StdioEvent[] = [];
33+
return {
34+
events,
35+
hook: (event: StdioEvent) => events.push(event),
36+
stdout: () =>
37+
events
38+
.filter((e) => e.channel === "stdout")
39+
.map((e) => e.message)
40+
.join("\n"),
41+
};
42+
}
43+
44+
// ---------------------------------------------------------------------------
45+
// flattenBindingTree validation (pure unit tests)
46+
// ---------------------------------------------------------------------------
47+
48+
describe("flattenBindingTree validation", () => {
49+
it("rejects invalid JS identifiers as binding keys", () => {
50+
expect(() => flattenBindingTree({ "123abc": () => 1 })).toThrow(
51+
"must be a valid JavaScript identifier",
52+
);
53+
expect(() => flattenBindingTree({ "foo-bar": () => 1 })).toThrow(
54+
"must be a valid JavaScript identifier",
55+
);
56+
expect(() => flattenBindingTree({ "": () => 1 })).toThrow(
57+
"must be a valid JavaScript identifier",
58+
);
59+
});
60+
61+
it("rejects nesting depth > 4", () => {
62+
const deep: BindingTree = { a: { b: { c: { d: { e: () => 1 } } } } };
63+
expect(() => flattenBindingTree(deep)).toThrow(
64+
"exceeds maximum nesting depth of 4",
65+
);
66+
});
67+
68+
it("accepts nesting depth exactly 4", () => {
69+
const ok: BindingTree = { a: { b: { c: { d: () => 1 } } } };
70+
expect(() => flattenBindingTree(ok)).not.toThrow();
71+
});
72+
73+
it("rejects > 64 leaf functions", () => {
74+
const tree: BindingTree = {};
75+
for (let i = 0; i < 65; i++) {
76+
tree[`fn${i}`] = () => i;
77+
}
78+
expect(() => flattenBindingTree(tree)).toThrow(
79+
"exceeds maximum of 64 leaf functions",
80+
);
81+
});
82+
83+
it("accepts exactly 64 leaf functions", () => {
84+
const tree: BindingTree = {};
85+
for (let i = 0; i < 64; i++) {
86+
tree[`fn${i}`] = () => i;
87+
}
88+
expect(() => flattenBindingTree(tree)).not.toThrow();
89+
});
90+
91+
it("rejects binding name collision with internal bridge names (underscore prefix)", () => {
92+
expect(() => flattenBindingTree({ _private: () => 1 })).toThrow(
93+
'starts with "_" which is reserved for internal bridge names',
94+
);
95+
expect(() => flattenBindingTree({ _fsReadFile: () => 1 })).toThrow(
96+
'starts with "_" which is reserved for internal bridge names',
97+
);
98+
});
99+
100+
it("flattens nested tree into __bind. prefixed keys", () => {
101+
const result = flattenBindingTree({
102+
db: { query: () => [], insert: async () => true },
103+
cache: { get: () => null },
104+
});
105+
const keys = result.map((r) => r.key);
106+
expect(keys).toContain(`${BINDING_PREFIX}db.query`);
107+
expect(keys).toContain(`${BINDING_PREFIX}db.insert`);
108+
expect(keys).toContain(`${BINDING_PREFIX}cache.get`);
109+
});
110+
111+
it("detects async functions correctly", () => {
112+
const result = flattenBindingTree({
113+
sync: () => 42,
114+
asyncFn: async () => 42,
115+
});
116+
const syncBinding = result.find((r) => r.key.endsWith("sync"))!;
117+
const asyncBinding = result.find((r) => r.key.endsWith("asyncFn"))!;
118+
expect(syncBinding.isAsync).toBe(false);
119+
expect(asyncBinding.isAsync).toBe(true);
120+
});
121+
});
122+
123+
// ---------------------------------------------------------------------------
124+
// Integration tests (sandbox execution)
125+
// ---------------------------------------------------------------------------
126+
127+
describe("custom bindings integration", () => {
128+
let driver: NodeExecutionDriver | undefined;
129+
130+
afterEach(() => {
131+
driver?.dispose();
132+
driver = undefined;
133+
});
134+
135+
it("round-trips values through nested bindings", async () => {
136+
driver = createDriverWithBindings({
137+
greet: (name: unknown) => `Hello, ${name}!`,
138+
math: {
139+
add: (a: unknown, b: unknown) => (a as number) + (b as number),
140+
},
141+
});
142+
143+
const result = await driver.run(`
144+
const greeting = SecureExec.bindings.greet("World");
145+
const sum = SecureExec.bindings.math.add(3, 4);
146+
module.exports = { greeting, sum };
147+
`);
148+
expect(result.code).toBe(0);
149+
expect(result.exports).toEqual({ greeting: "Hello, World!", sum: 7 });
150+
});
151+
152+
it("sync bindings return values directly", async () => {
153+
driver = createDriverWithBindings({
154+
getValue: () => 42,
155+
});
156+
157+
const result = await driver.run(`
158+
const val = SecureExec.bindings.getValue();
159+
module.exports = { val, type: typeof val };
160+
`);
161+
expect(result.code).toBe(0);
162+
expect(result.exports).toEqual({ val: 42, type: "number" });
163+
});
164+
165+
it("async bindings return resolved values synchronously through bridge", async () => {
166+
// The bridge dispatch mechanism resolves async handlers synchronously
167+
// via applySyncPromise — the result is returned directly, not as a Promise
168+
driver = createDriverWithBindings({
169+
fetchData: async () => ({ items: [1, 2, 3] }),
170+
});
171+
172+
const result = await driver.run(`
173+
const data = SecureExec.bindings.fetchData();
174+
module.exports = data;
175+
`);
176+
expect(result.code).toBe(0);
177+
expect(result.exports).toEqual({ items: [1, 2, 3] });
178+
});
179+
180+
it("SecureExec.bindings is frozen — mutation throws", async () => {
181+
driver = createDriverWithBindings({
182+
foo: () => 1,
183+
ns: { bar: () => 2 },
184+
});
185+
186+
const result = await driver.run(`
187+
const errors = [];
188+
189+
// Try to add a property to bindings
190+
try { SecureExec.bindings.newProp = 42; } catch (e) { errors.push("add-to-bindings"); }
191+
192+
// Try to overwrite an existing binding
193+
try { SecureExec.bindings.foo = () => 999; } catch (e) { errors.push("overwrite-binding"); }
194+
195+
// Try to add to nested namespace
196+
try { SecureExec.bindings.ns.baz = () => 3; } catch (e) { errors.push("add-to-ns"); }
197+
198+
// Try to overwrite SecureExec itself
199+
try { globalThis.SecureExec = {}; } catch (e) { errors.push("overwrite-secureexec"); }
200+
201+
// Try to delete SecureExec
202+
try {
203+
const deleted = delete globalThis.SecureExec;
204+
if (!deleted || globalThis.SecureExec !== undefined) errors.push("delete-secureexec-noop");
205+
} catch (e) { errors.push("delete-secureexec"); }
206+
207+
module.exports = errors;
208+
`);
209+
expect(result.code).toBe(0);
210+
// In strict mode, frozen object writes throw TypeError.
211+
// In sloppy mode, they silently fail. Either way, the originals are preserved.
212+
// The key assertion is that mutations don't succeed.
213+
const errors = result.exports as string[];
214+
expect(errors.length).toBeGreaterThan(0);
215+
});
216+
217+
it("frozen bindings preserve original values after mutation attempts", async () => {
218+
driver = createDriverWithBindings({
219+
getValue: () => "original",
220+
ns: { nested: () => "nested-original" },
221+
});
222+
223+
const result = await driver.run(`
224+
try { SecureExec.bindings.getValue = () => "hacked"; } catch {}
225+
try { SecureExec.bindings.ns.nested = () => "hacked"; } catch {}
226+
try { SecureExec.bindings.newProp = "injected"; } catch {}
227+
228+
module.exports = {
229+
getValue: SecureExec.bindings.getValue(),
230+
nested: SecureExec.bindings.ns.nested(),
231+
hasNewProp: "newProp" in SecureExec.bindings,
232+
};
233+
`);
234+
expect(result.code).toBe(0);
235+
expect(result.exports).toEqual({
236+
getValue: "original",
237+
nested: "nested-original",
238+
hasNewProp: false,
239+
});
240+
});
241+
242+
it("complex types serialize correctly through bindings", async () => {
243+
const testDate = new Date("2025-01-15T12:00:00.000Z");
244+
driver = createDriverWithBindings({
245+
getObject: () => ({ key: "value", num: 42, nested: { deep: true } }),
246+
getArray: () => [1, "two", { three: 3 }],
247+
getDate: () => testDate.toISOString(),
248+
getBinary: () => Array.from(new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF])),
249+
});
250+
251+
const result = await driver.run(`
252+
const obj = SecureExec.bindings.getObject();
253+
const arr = SecureExec.bindings.getArray();
254+
const dateStr = SecureExec.bindings.getDate();
255+
const binary = SecureExec.bindings.getBinary();
256+
257+
module.exports = { obj, arr, dateStr, binary };
258+
`);
259+
expect(result.code).toBe(0);
260+
const exports = result.exports as any;
261+
expect(exports.obj).toEqual({ key: "value", num: 42, nested: { deep: true } });
262+
expect(exports.arr).toEqual([1, "two", { three: 3 }]);
263+
expect(exports.dateStr).toBe("2025-01-15T12:00:00.000Z");
264+
expect(exports.binary).toEqual([0xDE, 0xAD, 0xBE, 0xEF]);
265+
});
266+
267+
it("SecureExec global exists even with no bindings registered", async () => {
268+
driver = createDriverWithBindings();
269+
270+
const result = await driver.run(`
271+
module.exports = {
272+
hasSecureExec: typeof SecureExec !== "undefined",
273+
hasBindings: typeof SecureExec !== "undefined" && "bindings" in SecureExec,
274+
bindingsKeys: typeof SecureExec !== "undefined" ? Object.keys(SecureExec.bindings) : null,
275+
};
276+
`);
277+
expect(result.code).toBe(0);
278+
expect(result.exports).toEqual({
279+
hasSecureExec: true,
280+
hasBindings: true,
281+
bindingsKeys: [],
282+
});
283+
});
284+
285+
it("raw __bind.* globals are not accessible from sandbox code after inflation", async () => {
286+
driver = createDriverWithBindings({
287+
secret: () => "hidden",
288+
ns: { inner: () => "also hidden" },
289+
});
290+
291+
const result = await driver.run(`
292+
const rawKeys = Object.keys(globalThis).filter(k => k.startsWith("__bind."));
293+
const directAccess = globalThis["__bind.secret"];
294+
const nestedAccess = globalThis["__bind.ns.inner"];
295+
296+
module.exports = {
297+
rawKeys,
298+
directAccess: directAccess === undefined,
299+
nestedAccess: nestedAccess === undefined,
300+
};
301+
`);
302+
expect(result.code).toBe(0);
303+
expect(result.exports).toEqual({
304+
rawKeys: [],
305+
directAccess: true,
306+
nestedAccess: true,
307+
});
308+
});
309+
});

0 commit comments

Comments
 (0)