cmd + R(esearch)

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
  1. ({}).constructor resolves to Object (the constructor of any plain object).
  2. Object.constructor resolves to Function (since Function constructed Object).
  3. Function("return this") creates a new function whose body is return this. Functions created via the Function constructor execute in the global scope, not in the caller's scope.
  4. When that function is called without a receiver (for instance func() rather than obj.func()), this binds to the global object (window in browsers, global in Node) under non-strict mode. In strict mode, this would be undefined instead.

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:

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:

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.