Skip to content

Commit 4bda920

Browse files
feat: enhance security by pinning Reflect methods to prevent shared-realm tampering
1 parent 4c3a2fe commit 4bda920

2 files changed

Lines changed: 85 additions & 14 deletions

File tree

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,48 @@ describe('createSafeReflect', () => {
653653
expect(safeReflect.has(obj, 'b')).toBe(true);
654654
expect([...safeReflect.ownKeys(obj)]).toEqual(expect.arrayContaining(['a', 'b']));
655655
});
656+
657+
// Regression: the wrapper used to read `Reflect` and its methods live, so a shared-realm
658+
// attacker who overwrote the namespace could have the sandbox hand out their own function.
659+
// Reads are now served from a snapshot taken at module load.
660+
describe('is pinned against later mutation of the shared-realm Reflect', () => {
661+
const originalGet = Reflect.get;
662+
const originalHas = Reflect.has;
663+
664+
afterEach(() => {
665+
Reflect.get = originalGet;
666+
Reflect.has = originalHas;
667+
delete (Reflect as unknown as Record<string, unknown>)['__injected'];
668+
});
669+
670+
it('ignores a method overwritten after module load', () => {
671+
const tampered = () => 'TAMPERED';
672+
Reflect.get = tampered as unknown as typeof Reflect.get;
673+
Reflect.has = tampered as unknown as typeof Reflect.has;
674+
675+
const safeReflect = createSafeReflect('STANDARD') as any;
676+
expect(safeReflect.get({ a: 1 }, 'a')).toBe(1);
677+
expect(safeReflect.has({ b: 2 }, 'b')).toBe(true);
678+
});
679+
680+
it('does not serve a property injected onto Reflect after module load', () => {
681+
(Reflect as unknown as Record<string, unknown>)['__injected'] = () => 'INJECTED';
682+
683+
const safeReflect = createSafeReflect('STANDARD') as any;
684+
expect(safeReflect.__injected).toBeUndefined();
685+
});
686+
687+
it('blocks Reflect.construct on the function constructors', () => {
688+
const safeReflect = createSafeReflect('STANDARD') as any;
689+
// eslint-disable-next-line @typescript-eslint/no-empty-function
690+
const AsyncFunction = async function () {}.constructor;
691+
692+
expect(() => safeReflect.construct(Function, ['return 1'])).toThrow();
693+
expect(() => safeReflect.construct(AsyncFunction, ['return 1'])).toThrow();
694+
// A benign constructor still works.
695+
expect(safeReflect.construct(Array, [3])).toHaveLength(3);
696+
});
697+
});
656698
});
657699

658700
describe('getOwnPropertyDescriptor trap must not leak raw references (review finding)', () => {

libs/core/src/secure-proxy.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,32 @@ const ReflectConstruct = Reflect.construct;
3636
// than whatever the `Reflect` binding resolves to when the safe runtime is assembled.
3737
const ReflectObject = Reflect;
3838

39+
/**
40+
* Snapshot of every own value on the pristine `Reflect` namespace, taken at module load.
41+
*
42+
* `createSafeReflect` serves reads from this snapshot instead of re-reading the namespace, so a
43+
* later `Reflect.get = evil` in a shared realm cannot be observed through the sandbox's Reflect.
44+
* Pinning the namespace object alone is not enough: the proxy target's own properties stay
45+
* writable, so only a value snapshot closes the window. Every own property of `Reflect` is a data
46+
* property (the methods plus `Symbol.toStringTag`), so building the snapshot triggers no getters.
47+
*/
48+
const PINNED_REFLECT_VALUES = new Map<string | symbol, unknown>();
49+
for (const key of ReflectOwnKeys(ReflectObject)) {
50+
PINNED_REFLECT_VALUES.set(key, ReflectGet(ReflectObject, key));
51+
}
52+
53+
// Function constructors, pinned at module load. `createSafeReflect`'s construct guard compares
54+
// against these: reading them live (`async function () {}.constructor`) walks a prototype whose
55+
// `constructor` is writable, so a shared-realm attacker could point the comparison at a decoy and
56+
// slip the real Function constructor past the check.
57+
const FunctionCtor = Function;
58+
// Intentional empty functions used only to reach the hidden function-constructor intrinsics.
59+
/* eslint-disable @typescript-eslint/no-empty-function */
60+
const AsyncFunctionCtor = async function () {}.constructor;
61+
const GeneratorFunctionCtor = function* () {}.constructor;
62+
const AsyncGeneratorFunctionCtor = async function* () {}.constructor;
63+
/* eslint-enable @typescript-eslint/no-empty-function */
64+
3965
/**
4066
* Absolute floor for the membrane recursion cap.
4167
*
@@ -259,33 +285,36 @@ export function createSafeReflect(securityLevel: SecurityLevel): typeof Reflect
259285
}
260286

261287
return new Proxy(ReflectObject, {
262-
get(target, prop: string | symbol) {
288+
get(_target, prop: string | symbol) {
263289
if (typeof prop === 'string' && dangerousMethods.has(prop)) {
264290
return undefined;
265291
}
266292

267-
const value = ReflectGet(target, prop);
293+
// Serve from the module-load snapshot: the proxy target's own properties are writable, so
294+
// re-reading them here would expose whatever the current realm holds. Keys absent from the
295+
// snapshot were not on the pristine namespace and are therefore never served.
296+
if (!PINNED_REFLECT_VALUES.has(prop)) {
297+
return undefined;
298+
}
299+
const value = PINNED_REFLECT_VALUES.get(prop);
268300

269301
// Wrap Reflect.construct to block Function constructors
270302
if (typeof value === 'function' && prop === 'construct') {
271303
const safeConstruct = function (ctorTarget: unknown, args: unknown[], newTarget?: unknown) {
272304
// Block Function, AsyncFunction, GeneratorFunction, AsyncGeneratorFunction constructors
273-
// Intentional empty functions to obtain constructor references
274-
// eslint-disable-next-line @typescript-eslint/no-empty-function
275-
const AsyncFunction = async function () {}.constructor;
276-
// eslint-disable-next-line @typescript-eslint/no-empty-function
277-
const GeneratorFunction = function* () {}.constructor;
278-
// eslint-disable-next-line @typescript-eslint/no-empty-function
279-
const AsyncGeneratorFunction = async function* () {}.constructor;
280-
281305
if (
282-
ctorTarget === Function ||
283-
ctorTarget === AsyncFunction ||
284-
ctorTarget === GeneratorFunction ||
285-
ctorTarget === AsyncGeneratorFunction
306+
ctorTarget === FunctionCtor ||
307+
ctorTarget === AsyncFunctionCtor ||
308+
ctorTarget === GeneratorFunctionCtor ||
309+
ctorTarget === AsyncGeneratorFunctionCtor
286310
) {
287311
throw createSafeError('Reflect.construct with function constructors is blocked', 'SecurityError');
288312
}
313+
// `Reflect.construct(t, a, undefined)` throws — newTarget is "present" as soon as a
314+
// third argument is passed. Forward the 2-argument form when the caller omitted it.
315+
if (newTarget === undefined) {
316+
return ReflectConstruct(ctorTarget as new (...args: unknown[]) => unknown, args as unknown[]);
317+
}
289318
return ReflectConstruct(
290319
ctorTarget as new (...args: unknown[]) => unknown,
291320
args as unknown[],

0 commit comments

Comments
 (0)