JS Sandbox Fundamentals: Understanding Proxy Objects
Proxy Object
The Proxy object, introduced in ES6, allows defining custom behavior for fundamental operations on objects: property lookup, assignment, enumeration, function invocation, and others.
A Proxy wraps a target object and routes these operations through a handler, whose methods are called traps. This interception layer is the core mechanism that makes JS-level sandboxing possible.
const target = { greeting: "hello" };
const handler = {
get(target, property, receiver) {
console.log(`accessed "${property}"`);
return Reflect.get(target, property, receiver);
},
};
const proxy = new Proxy(target, handler);
proxy.greeting;
// logs: accessed "greeting"
// returns: "hello"
When the engine resolves a property access on proxy, it invokes the get trap instead of performing a direct [[Get]] on the target. As long as code only holds a reference to the proxy and never to the underlying target, every operation is mediated by the handler (as you can possibly tell, getting a reference to the target bypasses the traps, this will become important later).
Traps
There are 13 traps total (full list on MDN). The relevant ones for sandboxing:
| Trap | Intercepts |
|---|---|
get |
obj.prop, obj["prop"] |
set |
obj.prop = val |
has |
"prop" in obj |
deleteProperty |
delete obj.prop |
apply |
func() |
construct |
new Ctor() |
getPrototypeOf |
Object.getPrototypeOf() |
ownKeys |
Object.keys(), for...in |
Why this matters for sandboxing
JavaScript's object graph is deeply connected. From almost any object, prototype chain traversal can reach powerful constructors like Function, eval, or Node's process:
Lets take the following code example:
({}).constructor.constructor("return this")();
// returns the global object in non-strict mode
({}).constructorresolves toObject(the constructor of any plain object).Object.constructorresolves toFunction(sinceFunctionconstructedObject).Function("return this")creates a new function whose body isreturn this. Functions created via theFunctionconstructor execute in the global scope, not in the caller's scope.- When that function is called without a receiver (for instance
func()rather thanobj.func()),thisbinds to the global object (windowin browsers,globalin Node) under non-strict mode. In strict mode,thiswould beundefinedinstead.
The result: starting from an empty object literal, two property accesses are enough to reach the global object. This is the kind of prototype chain escape that sandboxes need to prevent.
Proxies allow cutting these paths. Wrapping objects in a Proxy before passing them to untrusted code means the traps control what is reachable:
- Block dangerous properties: return
undefinedforconstructor,__proto__, etc. - Enforce read-only access: throw in
setanddeletePropertytraps. - Hide properties: make
hasreturnfalseandownKeysomit them. - Wrap recursively: when
getreturns an object, wrap it in another Proxy. This pattern is known as a "membrane".
Read-only membrane
function readOnly(target) {
return new Proxy(target, {
set() {
throw new Error("read-only");
},
deleteProperty() {
throw new Error("read-only");
},
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value === "object" && value !== null) {
return readOnly(value);
}
return value;
},
});
}
const config = readOnly({ db: { host: "localhost", port: 5432 } });
config.db.host; // "localhost"
config.db.port = 3306; // throws
Blocking prototype traversal
function safeWrap(target) {
return new Proxy(target, {
get(target, property, receiver) {
if (property === "constructor" || property === "__proto__") {
return undefined;
}
return Reflect.get(target, property, receiver);
},
getPrototypeOf() {
return null;
},
});
}
Reflect
Reflect provides a method for each of the 13 traps, implementing the default behavior of the corresponding internal operation ([[Get]], [[Set]], etc.). Inside a trap, calling Reflect forwards the operation to the target with standard semantics. Omitting it lets you override the behavior entirely.
Without Reflect, reproducing default semantics (prototype chain resolution, getter invocation, receiver propagation) would require manual reimplementation.
Invariants
The spec enforces invariants that traps cannot violate:
geton a non-configurable, non-writable data property must return the actual value.hascannot report a non-configurable property as non-existent.ownKeysmust include all non-configurable own properties.
This constrains sandbox design: if the target has non-configurable properties, the proxy is forced to expose them. This is why sandboxes typically use Object.create(null) as the target, avoiding inherited or non-configurable properties that would leak through invariant checks.
Revocable Proxies
Proxy.revocable() returns a proxy and a revoke function. After revocation, the engine throws a TypeError on any operation against the proxy:
const { proxy, revoke } = Proxy.revocable(target, handler);
proxy.greeting; // works
revoke();
proxy.greeting; // TypeError: proxy has been revoked
This is useful for invalidating references when a sandbox session ends, ensuring no retained proxy can reach the target.
A minimal sandbox sketch
Combining these concepts into a controlled global scope:
function createSandboxGlobal(allowedGlobals) {
const sandbox = Object.create(null);
for (const key of allowedGlobals) {
sandbox[key] = globalThis[key];
}
return new Proxy(sandbox, {
has() {
// The `with` statement uses [[HasProperty]] to resolve identifiers.
// Returning true for all lookups forces variable resolution
// through this proxy instead of reaching the real global scope.
return true;
},
get(target, property) {
if (property in target) return target[property];
return undefined;
},
set(target, property, value) {
target[property] = value;
return true;
},
});
}
const sandboxGlobal = createSandboxGlobal(["Math", "parseInt", "JSON"]);
This is not production-ready. Real sandboxes need to handle prototype chain escapes, Symbol.unscopables, cross-realm object leaks, and more. But the core pattern holds: a Proxy mediates every interaction between untrusted code and the host environment, and the traps define the boundary.