This document specifies the design of a JavaScript membrane library that enables sandboxed code execution with controlled object graph sharing between isolated realms. The membrane acts as an intermediary layer that intercepts all cross-realm communication, enabling value transformation, access control, and mutation isolation.
JavaScript applications frequently need to execute code with different trust levels within the same process. Examples include:
- Third-party plugins or extensions
- User-provided scripts
- Embedded widgets from external sources
These scenarios require controlled isolation: the untrusted code should be able to interact with host APIs, but modifications should not corrupt the host's object graph, and access to sensitive capabilities should be controllable.
Without isolation, code can:
- Modify prototypes (
Array.prototype.map = malicious) - Add properties to shared objects
- Replace global functions
JavaScript realms have separate intrinsics. An Array from Realm A has a different Array.prototype than Realm B, causing:
instanceofchecks to fail across realms (e.g.,iframeArray instanceof Arrayreturnsfalse)- Prototype chain mismatches when extending built-ins
- Constructor identity checks to fail (
arr.constructor === Arrayreturnsfalse)
Browser environments contain non-configurable properties that cannot be virtualized:
window.location- The
windowprototype chain (Window.prototype → WindowProperties.prototype → EventTarget.prototype) - Certain DOM properties
Sandboxed code might access capabilities that should be restricted:
- Network APIs (
fetch,XMLHttpRequest) - Storage APIs (
localStorage,IndexedDB) - DOM manipulation outside designated areas
-
Transparency: Sandboxed code should not be able to detect that it is running in a sandbox (absent explicit capability restrictions). Specifically:
-
Top-Level Illusion: Code in the sandbox MUST appear to be running at the top level of the browser/environment. References to
window,self,globalThis,top,parent, andframesshould all resolve to the sandbox's virtualized global object, not reveal the actual underlying realm implementation (iframe, ShadowRealm[1], or other). -
No Observable Nesting: The sandbox must not expose evidence of its underlying realm implementation (iframe-based, ShadowRealm-based[1], or otherwise). Checks like
window === window.top,window === window.parent, andwindow.frameElement === nullshould behave as if the code is running in a top-level browsing context. -
Consistent Global Identity:
window === globalThis === selfmust hold true within the sandbox, and these must all reference the virtualized global object that the membrane controls. -
DOM Tree Root Appearance: When the sandbox interacts with the DOM, it should observe the host document as if it were the top-level document, not a document embedded within a frame.
-
-
Integrity Preservation: The host realm's object graph must remain unmodified by sandbox operations
-
Identity Continuity: Objects crossing the membrane should maintain consistent identity semantics
-
Controlled Capability Access: Host can define transformations ("distortions") applied to values crossing the membrane
-
Security Guarantees: The membrane provides integrity, not security. Security policies must be implemented via the distortion mechanism.
-
Performance Parity: Some overhead is acceptable for the isolation guarantees provided. However, optional escape hatches (such as marking typed arrays and frequently-accessed objects as "fast targets" or enabling
maxPerfMode) allow implementations to trade some isolation guarantees for performance-critical paths. -
Complete DOM Virtualization: Unforgeable browser constructs are handled but not fully virtualized.
The following terms are used throughout this document:
| Term | Definition |
|---|---|
| Membrane | An intermediary layer that intercepts all cross-realm communication |
| Realm | A JavaScript execution environment with its own global object and intrinsics |
| Intrinsic | A built-in object provided by the JavaScript runtime (Array, Object, etc.) |
| Blue Realm | The host/incubator realm that creates the sandbox |
| Red Realm | The sandboxed/child realm where untrusted code executes |
| Blue Value | Any value (primitive, object, function) originating in Blue Realm |
| Red Value | Any value originating in Red Realm |
| Blue Proxy | A Proxy object in Blue Realm that wraps a Red Value |
| Red Proxy | A Proxy object in Red Realm that wraps a Blue Value |
| Pointer | A callable function that provides indirect access to a value across the membrane |
| Shadow Target | A local placeholder object used to satisfy proxy invariants |
| Distortion | A transformation applied to values crossing the membrane |
| Live Proxy | A proxy that forwards mutations to the foreign target |
| Static Proxy | A proxy that operates on a local snapshot of the foreign target |
| Unforgeable | A property that cannot be reconfigured or deleted |
The membrane is a conceptual boundary between realms. All non-primitive values crossing this boundary are wrapped in Proxy objects. The membrane ensures:
- Wrapping: Objects/functions crossing the boundary become proxies
- Unwrapping: When a wrapped value returns to its origin realm, it is unwrapped
- Identity Preservation: The same underlying value always produces the same proxy
┌─────────────────┐ ┌─────────────────┐
│ BLUE REALM │ │ RED REALM │
│ │ │ │
│ blueObject ◄───┼── unwrap ──────────┼─── redProxy │
│ │ │ │
│ blueProxy ────┼── wrap ───────────►┼─── redObject │
│ │ │ │
└─────────────────┘ └─────────────────┘
MEMBRANE
| Value Type | Transfer Behavior |
|---|---|
Primitives (string, number, boolean, null, undefined, symbol, bigint) |
Pass through unchanged |
| Objects | Wrap in Proxy on receiving side |
| Functions | Wrap in Proxy on receiving side |
| Arrays | Wrap in Proxy on receiving side |
| Proxies from opposite realm | Unwrap to original value |
| Proxies from same realm | Wrap again (avoid double-wrapping through identity map) |
┌─────────────────────────────────────────────────────────────────┐
│ Virtual Environment │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ - evaluate(sourceText): Execute code in sandbox │ │
│ │ - link(...keys): Link intrinsic identity paths │ │
│ │ - remapProperties(target, descriptors): Override props │ │
│ │ - lazyRemapProperties(target, keys): Lazy prop setup │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────┴───────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Blue Connector │◄───────►│ Red Connector │ │
│ │ (Host Realm Side) │ │ (Sandbox Side) │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Membrane Marshall │ │ Membrane Marshall │ │
│ │ (Blue Instance) │◄───────►│ (Red Instance) │ │
│ └─────────────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
A Connector is a factory function that initializes one side of the membrane. It:
- Takes a "color" identifier ("blue" or "red")
- Receives a callback to register its hooks
- Returns a function to receive the foreign realm's hooks
type Connector = (
color: string,
registerHooks: HooksCallback,
options?: ConnectorOptions
) => HooksCallback;Blue Connector Creation: Requires direct access to the host's globalThis
Red Connector Creation: Requires an evaluator function (eval) from the sandbox realm
The Membrane Marshall is a self-contained, portable function that can be serialized and evaluated in any realm. When executed, it produces a Connector for that realm.
Key design constraint: The marshall function must not close over any external variables. All dependencies must be derived from the realm's intrinsics at evaluation time.
Pointers are the mechanism for passing object/function references between realms without directly sharing the values.
A Pointer is a callable function that, when invoked, makes its associated value available for retrieval. This design:
- Avoids passing actual object references across the boundary
- Enables lazy proxy creation
- Provides a uniform interface for all non-primitive values
type Pointer = () => void;
// Creating a pointer for a value
function createPointer(value: object | Function): Pointer {
return () => { selectedTarget = value; };
}
// Retrieving the value from a pointer
function getValueFromPointer(pointer: Pointer): object | Function {
pointer();
const value = selectedTarget;
selectedTarget = undefined;
return value;
}The membrane operates through a set of callable hooks that each side provides to the other. These hooks enable cross-realm operations:
| Hook Category | Purpose |
|---|---|
| Target Management | Push/register targets, link pointers |
| Proxy Traps | apply, construct, get, set, defineProperty, deleteProperty, etc. |
| Introspection | getPrototypeOf, getOwnPropertyDescriptor, ownKeys, isExtensible |
| Integrity | preventExtensions, getTargetIntegrityTraits |
| Utilities | evaluate, serializeTarget, debugInfo |
JavaScript Proxies have invariants that must be satisfied. For example, if the target is non-extensible, preventExtensions must return true. Since the actual target exists in another realm, we use a shadow target:
- An empty object/array/function in the local realm
- Satisfies proxy invariants
- Never actually used for operations (all operations forwarded to foreign target)
function createShadowTarget(traits: TargetTraits): ShadowTarget {
if (traits.isFunction) {
return traits.isArrowFunction ? () => {} : function() {};
}
if (traits.isArray) {
return [];
}
return {};
}Each proxy trap follows this pattern:
- Receive the operation on the shadow target
- Translate arguments to transferable form (pointers for objects)
- Forward to the foreign realm via the appropriate callable hook
- Translate the result back (invoke pointers, unwrap to local proxies)
- Return the result
Example for get trap:
get(shadowTarget, key, receiver):
1. foreignTargetPointer = this.foreignTargetPointer
2. transferableReceiver = getTransferableValue(receiver)
3. resultPointerOrPrimitive = foreignCallableGet(
foreignTargetPointer,
key,
transferableReceiver
)
4. if resultPointerOrPrimitive is function (pointer):
invoke it to select target
return selectedTarget (now a local proxy)
else:
return resultPointerOrPrimitive (primitive)
Proxies transition through states based on usage patterns:
| State | Behavior |
|---|---|
| Lazy | Initial state; descriptor lookups forwarded to foreign realm |
| Live | Mutations pass through to foreign target (for arrays, typed arrays) |
| Static | Frozen snapshot; all operations use shadow target |
| Revoked | Proxy has been revoked; all operations throw |
Static Transition: Occurs when the foreign target's integrity changes (frozen/sealed/non-extensible). The shadow target is synchronized once, then used for all future operations.
Certain intrinsics must maintain identity across realms to preserve JavaScript semantics. These are "linked":
const LinkedIntrinsics = [
'Array', 'Object', 'Function', 'Error',
'EvalError', 'RangeError', 'ReferenceError',
'SyntaxError', 'TypeError', 'URIError',
'Proxy', 'globalThis', 'eval'
];Linking Process:
- For each linked intrinsic name
- Get the pointer to Blue realm's value at that path
- Get the pointer to Red realm's value at that path
- Register bidirectional pointer mappings
- When either side encounters the other's value, it unwraps to local equivalent
Other intrinsics are remapped from the Blue realm to ensure consistent behavior:
const RemappedIntrinsics = [
'Map', 'Set', 'WeakMap', 'WeakSet',
'RegExp', 'JSON', 'Math', 'Reflect',
'Symbol', 'Number', 'String', 'Boolean',
'BigInt', 'Promise', 'Date', 'Intl'
];The sandbox's global object has these properties replaced with proxies to the Blue realm's versions.
When setting up the sandbox's global object, certain keys are filtered:
- Linked intrinsics: Handled separately via linking
- Dangerous intrinsics:
eval,Function(require special handling) - Environment-specific: Keys that shouldn't cross the boundary
A distortion is a transformation applied to values as they cross the membrane. The distortion callback receives the original value and returns a replacement:
type DistortionCallback = (value: any) => any;Distortions are applied when:
- A Blue value is about to be wrapped for Red realm access
- The distortion callback is invoked with the original value
- The returned value (possibly different) is what gets wrapped
| Use Case | Distortion Implementation |
|---|---|
| Block API access | Return undefined or throw |
| Restrict capability | Return wrapper with subset of functionality |
| Transform behavior | Return alternative implementation |
| Audit/log access | Return wrapper that logs then delegates |
A fundamental property of JavaScript is that functions execute in the realm where they were defined, not in the realm where they are called. The membrane preserves this behavior, which has important implications:
When Red realm code calls a Blue Proxy that wraps a Blue function, and that Blue function internally references eval or any other intrinsic, it accesses the Blue realm's eval—not the Red Proxy of eval that exists in the Red realm. Conversely, when Red realm code directly calls eval, it always invokes the Red realm's own eval, regardless of how that code was initiated or what Blue functions may be on the call stack.
Red Realm Blue Realm
─────────────────────────────────────────────────────────────
blueProxy(code) ──────────────────► blueFunction(code) {
│ │
│ ▼
│ eval(code) ◄── Blue eval, NOT Red Proxy
│ │
◄──────────────────────────────────┘
│
result
-
Distortions Don't Affect Internal Calls: If a Blue function internally calls
fetch,setTimeout, oreval, distortions applied to those APIs in the Red realm have no effect on those internal calls. -
Capability Boundaries: A Blue function retains its full Blue realm capabilities even when invoked from Red realm code. The membrane only interposes at the boundary—it does not recursively wrap internal operations.
-
Security Consideration: This means that passing a Blue function to the Red realm is effectively granting the Red realm access to whatever that function can do, including any Blue realm capabilities it uses internally.
This realm-of-definition behavior is why the membrane uses connectors evaluated in each realm. The Red connector's membrane marshall code is evaluated inside the Red realm, so its internal references to Proxy, WeakMap, Reflect, etc. resolve to the Red realm's intrinsics—ensuring the membrane machinery itself operates correctly in each realm.
Browsers currently lack a built-in lightweight realm creation API. The ShadowRealm proposal[1] aims to address this, but until it is widely available, the solution uses same-origin iframes:
- Create a hidden iframe
- Set
sandbox="allow-same-origin allow-scripts" - Append to document body
- Extract
contentWindowas the Red realm's global - Detach iframe from DOM (optional, based on
keepAliveoption which can be used for debugging)
A detached iframe:
- Loses its origin (no network access, no storage access)
- Cannot execute dynamic imports
- Retains JavaScript intrinsics and execution capability
- Cannot access opener/top window references
- Eliminates direct object access to parent
The window prototype chain is non-configurable:
window → Window.prototype → WindowProperties.prototype → EventTarget.prototype
Strategy (iframe-based only; not applicable to ShadowRealm):
- Link these prototypes between realms (identity preservation)
- Remap their property descriptors to neutralize dangerous capabilities
- Lazy remap
EventTarget.prototypemethods to Blue realm versions
With keepAlive: true, the iframe's document and window are added to a revoked set. Any attempt to access these through the membrane returns a revoked proxy. keepAlive should typically only be used for debugging purposes—production environments will be vulnerable if the keepAlive option is true in those deployments.
When available, the ShadowRealm API[1] provides a purpose-built mechanism for creating isolated JavaScript execution contexts:
const shadowRealm = new ShadowRealm();
// Evaluate code in the ShadowRealm
const result = shadowRealm.evaluate('1 + 1'); // 2
// Import a module value
const fn = await shadowRealm.importValue('./module.js', 'exportedFunction');ShadowRealm enforces a callable boundary with specific transfer semantics:
| Value Type | Transfer Behavior |
|---|---|
| Primitives | Pass through unchanged |
| Callable objects (functions) | Wrapped in a "Wrapped Function Exotic Object" |
| Non-callable objects | Throw TypeError — cannot cross the boundary |
This differs from the membrane's proxy-based approach. The membrane wraps all objects in proxies, allowing non-callable objects to cross the boundary. ShadowRealm's stricter callable-only boundary means:
-
Direct object sharing is prohibited: Code like
shadowRealm.evaluate('({ foo: 1 })')throws because the result is a non-callable object. -
Functions become wrapped functions: Callable values are wrapped in Wrapped Function Exotic Objects that marshal arguments and return values across the boundary.
-
Identity isolation is enforced by the spec: The specification requires that "an execution in a ShadowRealm is oblivious of host or implementation-defined APIs and cannot observe the identity of an object" from another realm, and vice versa.
To use ShadowRealm as the Red realm while maintaining full membrane semantics (proxied object sharing), the membrane must:
-
Bootstrap via
evaluate(): Inject the membrane marshall code into the ShadowRealm usingshadowRealm.evaluate(marshallSourceText). -
Exchange callable hooks: Since only functions can cross the ShadowRealm boundary, the pointer/hook exchange mechanism naturally aligns—pointers are already callable functions.
-
Virtualize the global object: The ShadowRealm's global object is a plain ordinary object (no
window, no DOM), which simplifies setup—there are no unforgeables to handle. -
Handle the callable boundary: When the membrane needs to pass a non-callable object, it wraps it in a pointer (a callable) first, which can then cross the ShadowRealm boundary.
| Aspect | Iframe | ShadowRealm |
|---|---|---|
| Creation overhead | Heavy (DOM, browsing context) | Lightweight (JS realm only) |
| Global object | Full window with DOM |
Plain object, host-configurable |
| Unforgeables | Must be neutralized | None (no window prototype chain) |
| Module loading | Blocked in detached iframe | Supported via importValue() |
| CSP implications | Requires unsafe-eval* |
Requires unsafe-eval* |
| Specification status | Stable platform feature | Stage 2.7 (as of February 2025) |
* unsafe-eval is being replaced by Trusted Types/CSP directive trusted-types-eval[2]
Each realm maintains a WeakMap from values to their pointers:
const pointerCache = new WeakMap<object | Function, Pointer>();
function getTransferablePointer(value: object | Function): Pointer {
let pointer = pointerCache.get(value);
if (pointer === undefined) {
pointer = createPointer(value);
pointerCache.set(value, pointer);
}
return pointer;
}Each realm maintains a WeakMap from foreign pointers to local proxies:
const proxyCache = new WeakMap<Pointer, Proxy>();
function getOrCreateProxy(foreignPointer: Pointer): Proxy {
let proxy = proxyCache.get(foreignPointer);
if (proxy === undefined) {
proxy = createBoundaryProxy(foreignPointer);
proxyCache.set(foreignPointer, proxy);
}
return proxy;
}When a value crosses the membrane and returns:
- Same pointer is used → same proxy is retrieved
- No "proxy of proxy" accumulation
- Identity checks (
===) work correctly
Eagerly creating proxies for all global properties would be expensive. Most programs only access a subset of available APIs.
Properties are registered for lazy remapping:
env.lazyRemapProperties(globalObject, [
'document', 'console', 'fetch', 'setTimeout', ...
]);When a property is first accessed:
- The lazy descriptor's getter is invoked
- The actual descriptor is fetched from the foreign realm
- A proxy is created for the value
- The lazy descriptor is replaced with the real one
- The value is returned
A state object tracks which properties have been materialized:
interface LazyPropertyDescriptorState {
[key: PropertyKey]: boolean; // true = materialized
}When an error occurs in the foreign realm:
- The error object is pushed as a target (like any other value)
- A pointer to the error is returned
- The local realm creates a proxy for the error
- The proxy is thrown locally
Linked error constructors ensure:
error instanceof Errorworks in both realms- Error prototype chain is consistent
- Stack traces are preserved (with filtering for internal frames)
In debug mode, internal membrane frames are collapsed:
- Frames containing the membrane marker are grouped
- Displayed as single "LWS" (Locker Web Security) entry
- Reduces noise in developer tools
Certain objects are "serialized" rather than proxied:
- Boxed primitives (
new Boolean(true),new Number(42)) - RegExp objects (serialized as source + flags)
The membrane detects serializable objects via:
- Brand checking (
Object.prototype.toString) - Internal slot probing (try/catch valueOf calls)
Serialized values are transferred as primitives:
- The primitive representation crosses the membrane
- The receiving side reconstructs the equivalent object
- No proxy is created
Frequently accessed targets can be marked for fast path access:
env.trackAsFastTarget(frequentlyUsedObject);Fast targets:
- Skip lazy descriptor lookup
- Use direct property access callable
- Reduce trap invocation overhead
Function proxies have specialized trap implementations based on argument count:
applyTrapForZeroArgsapplyTrapForOneArgapplyTrapForTwoArgsapplyTrapForAnyNumberOfArgs
This avoids array allocation for common cases.
All caches use WeakMap to:
- Avoid memory leaks from retained proxies
- Allow garbage collection of unreferenced values
- Maintain identity without preventing cleanup
| Option | Type | Description |
|---|---|---|
distortionCallback |
(value) => value |
Transform values crossing membrane |
liveTargetCallback |
(target, traits) => boolean |
Mark targets as live (passthrough mutations) |
revokedProxyCallback |
(target) => boolean |
Identify values that should produce revoked proxies |
signSourceCallback |
(source) => source |
Sign/transform source code before evaluation |
instrumentation |
Instrumentation |
Debugging/monitoring hooks |
| Option | Type | Description |
|---|---|---|
keepAlive |
boolean |
Keep iframe attached (default: true) |
endowments |
PropertyDescriptorMap |
Additional properties for sandbox global |
globalObjectShape |
object |
Custom shape for global object |
maxPerfMode |
boolean |
Skip certain virtualizations for performance |
defaultPolicy |
TrustedTypePolicy |
Trusted Types policy for sandbox |
The following invariants must be maintained:
- Primitives MUST pass through unchanged
- Objects/functions MUST be wrapped when crossing the membrane
- The same value MUST always produce the same pointer
- The same pointer MUST always produce the same proxy
- Shadow target MUST satisfy JavaScript proxy invariants
- Non-configurable properties on shadow target MUST match foreign target
- Non-extensible shadow target MUST remain non-extensible
- Revoked proxies MUST throw on all operations
blueProxy !== redValue(proxies are distinct objects)unwrap(wrap(value)) === value(round-trip identity)- Linked intrinsics MUST resolve to local equivalents
- Static proxies MUST NOT reflect mutations to foreign target
- Live proxies MUST reflect mutations to foreign target
- Sandbox mutations MUST NOT affect host realm (unless live)
An implementation should verify:
- Primitive value transfer
- Object proxy creation
- Function invocation across membrane
- Property access and mutation
- Same object produces same proxy
- Round-trip identity preservation
- Linked intrinsic resolution
instanceofworks across membraneObject.getPrototypeOfreturns correct value- Custom class inheritance works
- Distortion callback is invoked
- Returned value is used instead of original
- Distortions apply to nested access
- Revoked proxies throw appropriately
- Frozen/sealed objects handled correctly
- Symbol properties transfer correctly
- Getters/setters work across membrane
While this design provides integrity (the host's object graph is protected), it does not provide security by default. Implementers must:
- Use distortions to block or restrict sensitive APIs
- Validate all inputs from the sandbox
- Consider side channels (timing, memory usage)
- Audit the membrane code for bypass vulnerabilities
- Keep the membrane updated as JavaScript evolves
The membrane is a building block for security, not a complete security solution.
[1] ShadowRealm Proposal — TC39 proposal (Stage 2.7 as of February 2025) for a lightweight realm creation API that provides isolated JavaScript execution contexts with their own global object and intrinsics. The proposal enforces a callable boundary where only primitives and callable objects can cross between realms.
- Specification: https://tc39.es/proposal-shadowrealm/
- Proposal Repository: https://github.com/tc39/proposal-shadowrealm
[2] trusted-types-eval — CSP directive for Trusted Types integration with eval.