Skip to content

Commit d35273b

Browse files
feat: enhance security by implementing pinned property handling in proxies and tests
1 parent d8ff1ce commit d35273b

3 files changed

Lines changed: 26 additions & 12 deletions

File tree

docs/enclave/core-libraries/enclave-browser/security-architecture.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ Every global object exposed to user code is wrapped in a `Proxy` that blocks acc
136136

137137
The proxy behavior is configurable per security level. At `STRICT` and `SECURE` levels, accessing blocked properties throws an error. At `PERMISSIVE`, it returns `undefined`.
138138

139+
#### Pinned properties
140+
141+
A JavaScript `get` trap must report the exact value of a non-configurable, non-writable own data property, so the proxy can neither wrap nor hide one. Primitives in that position are reported as-is — this covers constants such as `Math.PI` and hardened slots pinned to `undefined`. An **object or function** in that position is refused with a `SecurityError` regardless of `throwOnBlocked`, because returning the reference would put an unwrapped object graph in reach of sandboxed code, and that graph's prototype chain leads to a `Function` constructor.
142+
143+
This matters for host values that pin object-valued internals: a Zod schema pins `_zod` this way, and every class pins `prototype`. Hand sandboxed code plain serialized data rather than live host objects — a structurally cloned or JSON-projected value has no pinned members and reads normally.
144+
139145
Additionally, dangerous static methods on `Object` are neutralized: `defineProperty`, `defineProperties`, `setPrototypeOf`, `getOwnPropertyDescriptor`, and `getOwnPropertyDescriptors`.
140146

141147
### Layer 7: Safe Runtime Wrappers

libs/core/src/__tests__/enclave.host-reference-leak.spec.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -284,13 +284,25 @@ describe('custom-global results must not leak a raw host object via a pinned pro
284284
}
285285
}
286286

287+
// Disposal must happen even when an assertion throws, otherwise a failing test leaves its
288+
// worker pool alive and jest hangs on the surviving handles.
289+
const created: Enclave[] = [];
290+
291+
afterEach(() => {
292+
while (created.length > 0) {
293+
created.pop()?.dispose();
294+
}
295+
});
296+
287297
function enclaveWithHostGlobals(globals: Record<string, unknown>): Enclave {
288-
return new Enclave({
298+
const enclave = new Enclave({
289299
securityLevel: 'STANDARD',
290300
toolHandler: async () => ({ ok: true }),
291301
allowFunctionsInGlobals: true,
292302
globals,
293303
});
304+
created.push(enclave);
305+
return enclave;
294306
}
295307

296308
it('blocks the reported PoC (pinned property → host Function → execSync)', async () => {
@@ -308,8 +320,9 @@ describe('custom-global results must not leak a raw host object via a pinned pro
308320
`;
309321
const result = await enclave.run(code);
310322
assertNoRce(result);
323+
// The run must fail at the membrane, not somewhere incidental further down the chain.
311324
expect(result.success).toBe(false);
312-
enclave.dispose();
325+
expect(result.error?.message).toMatch(/Access to '_zod' is blocked/);
313326
});
314327

315328
it('refuses the pinned property itself rather than returning a raw reference', async () => {
@@ -328,7 +341,6 @@ describe('custom-global results must not leak a raw host object via a pinned pro
328341
const result = await enclave.run(code);
329342
expect(result.success).toBe(true);
330343
expect(result.value).toEqual({ denied: expect.stringMatching(/blocked/i) });
331-
enclave.dispose();
332344
});
333345

334346
it('stays strict through nested reads and chained calls', async () => {
@@ -349,7 +361,6 @@ describe('custom-global results must not leak a raw host object via a pinned pro
349361
const result = await enclave.run(code);
350362
expect(result.success).toBe(true);
351363
expect(result.value).toEqual({ denied: expect.stringMatching(/blocked/i) });
352-
enclave.dispose();
353364
});
354365

355366
it('still exposes primitive-valued pinned properties from host globals', async () => {
@@ -370,7 +381,6 @@ describe('custom-global results must not leak a raw host object via a pinned pro
370381
const result = await enclave.run(code);
371382
expect(result.success).toBe(true);
372383
expect(result.value).toBe(3);
373-
enclave.dispose();
374384
});
375385

376386
it('still passes ordinary host-global data through unchanged', async () => {
@@ -386,11 +396,10 @@ describe('custom-global results must not leak a raw host object via a pinned pro
386396
const result = await enclave.run(code);
387397
expect(result.success).toBe(true);
388398
expect(result.value).toEqual({ name: 'users:list', kind: 'object', prop: 'number' });
389-
enclave.dispose();
390399
});
391400

392401
it('leaves realm-owned intrinsics usable (host mode must not touch them)', async () => {
393-
const enclave = new Enclave({ securityLevel: 'STANDARD', toolHandler: async () => ({ ok: true }) });
402+
const enclave = enclaveWithHostGlobals({});
394403
const code = `
395404
async function __ag_main() {
396405
return {
@@ -403,6 +412,5 @@ describe('custom-global results must not leak a raw host object via a pinned pro
403412
const result = await enclave.run(code);
404413
expect(result.success).toBe(true);
405414
expect(result.value).toEqual({ joined: 'x-x-x', repeated: 'abab', parsed: 1 });
406-
enclave.dispose();
407415
});
408416
});

libs/core/src/__tests__/secure-proxy.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -410,15 +410,15 @@ describe('SecureProxy', () => {
410410
expect(() => (proxy as any).pinned).toThrow(/blocked/i);
411411
});
412412

413-
it('should not leak a frozen class prototype through the blocked-property path', () => {
413+
it('should refuse a class prototype, which is pinned by the language', () => {
414414
class Widget {
415415
value = 1;
416416
}
417-
Object.freeze(Widget.prototype);
418417
const proxy = createSecureProxy(Widget) as unknown as Record<string, unknown>;
419418

420-
// `prototype` on a class is non-configurable and non-writable, so the old invariant
421-
// concession returned it raw — from there `.constructor.constructor` is host Function.
419+
// `prototype` on a class is already non-configurable and non-writable, so this read is
420+
// decided by the invariant branch rather than the blocked-property list. The old
421+
// concession returned it raw, and `.constructor.constructor` from there is host Function.
422422
expect(() => proxy['prototype']).toThrow(/blocked/i);
423423
});
424424

0 commit comments

Comments
 (0)