Skip to content

Isolated realms with sync messaging passing #289

Closed
@domenic

Description

@domenic

Hi realms champions,

@syg and I have been considering a modification to the current realms proposal which trades some expressivity, to give better isolation guarantees. Essentially, instead of allowing direct bidirectional access between the parent realm and the constructed realm, all such communication would go through structured cloning. This ensures that the child realm never gets access to objects from the parent realm, thus making "sandbox escapes" such as those in #277 or nodejs/node#15673 impossible by construction.

Sample code and API

We don't have strong feelings on the API for this; we'd like it to be as ergonomic as possible. But here is an initial idea.

For pulling values out of the constructed realm, into the parent realm: introduce realm.eval().

const realm = new Realm();

// value is a structured clone of the completion value
const value = realm.eval("[1, { foo: 'bar' }]");

// Its prototype chain is thus based on *the parent realm*'s intrinsics
console.assert(value.__proto__ === Array.prototype);
console.assert(value[1].__proto__ === Object.prototype);

// If you try to get access to a constructed realm's prototype or constructor,
// you get a clone, which isn't very useful:

try {
  const value = realm.eval("Array");
} catch (e) {
  // Can't clone functions
}

const value2 = realm.eval("Array.prototype");
// value2 is an empty plain object (structured clone only clones enumerable properties)

For getting values into the constructed realm, from the parent realm, a bare minimum might look like this:

const realm = new Realm();

realm.set("foo", "bar");
console.assert(realm.eval("globalThis.foo === 'bar'") === true);

but you could imagine something slightly more complicated, and more useful, such as

const realm = new Realm();
realm.eval("globalThis.add = (x, y) => x + y");

const result = realm.call("add", 2, 3);
console.assert(result === 5);

(Compared to async realm boundaries on the web, this solves similar use cases to webRealm.postMessage().)

Finally, for pushing values out of the constructed realm into the parent realm, you'd need something like this:

const realm = new Realm({
  handler(...args) {
    console.assert(args[2].__proto__ == Object.prototype);
    console.log(args);
  },
  exposeHandlerAs: "callParent"
});

console.log(realm.eval("globalThis.callParent.toString()"));
// logs "function () { [native code] }": callParent is installed by the implementation inside
// the constructed realm, and structured-clones its arguments to pass to handler()
// in the outer realm.

realm.eval("globalThis.callParent(1, 2, { foo: 'bar' })");
// logs 1, 2, and (a structured clone of) the { foo: 'bar' } object

(Compared to async realm boundaries on the web, this solves similar use cases to webRealm.onmessage.)

And, of course, we'd remove realm.globalThis.

Use case analysis

This proposal is arguably better than the current one for many sandboxing use cases. In particular, for cases such as templating or computation where the goal is to have a (conceptually) pure function execute inside the realm, this architecture is ideal, especially in how it automatically prevents "impurities" from cross-realm contamination. In such cases, the values passed are often primitives, or if not, they're within the realm of structured clone: plain objects, arrays, maybe some Maps and Sets and Errors and Dates and typed arrays/ArrayBuffers.

For cases such as a virtualized environment, it requires more work, but probably on about the same level membrane-based approaches. That is, to perform operations inside the realm while interfacing with a same-realm object API, you would have to create proxies (either literal Proxys or just wrappers) which perform the appropriate calls to realm.eval() and realm.call(). And similarly for the reverse: if code inside the realm wants to operate on a inside-realm object while really doing work in the outside realm, the outside realm would need to do some setup, using realm.eval() to inject some proxies which call globalThis.callParent(). (Probably that setup code would then also do realm.eval("delete globalThis.callParent") at the end.) This is equivalent to what is being done today in the AMP WorkerDOM example that the explainer cites, but by using synchronous realm.call() etc. instead of asynchronous worker.postMessage(), it would overcome the challenges you discuss there.

Other use cases like running tests in the realm fall in between. You'd need to inject a small shim into the realm which provides globals that the test library depends on (such as console), proxied to the creator realm. But then you'd just run the test library inside the global. I.e. instead of the explainer's current sample code, you'd write

import { consoleShimOutside } from 'console-shim';
const realm = new Realm(consoleShimOutside);
realm.import('console-shim/inside');
realm.import('testFramework');
realm.import('./main-spec.js');

This also gives you a stronger guarantee that tests don't mess with the test framework, or with the outer realm, or with other tests, all of which are possible in the current explainer's sample code.

This proposal does lose some expressivity though. In particular, it is not able to create reference cycles between cross-realm objects. Because all communication is via cloned messages, there's no way to communicate to the garbage collector that an object in the outer realm depends on an object in the inner realm, and vice versa, so that the cycle can take part in liveness detection. To some extent this is a good thing, as cycles are an easy way to leak an entire realm. But from what I understand it does cut off some use cases that go beyond the ones mentioned in the current realms explainer.

Performance

Adding a structured clone step for all boundary-crossing operations could come at a performance cost. But, less so than you'd imagine.

In particular, since primitives are trivially cloneable, any operation which returns them would suffer virtually no overhead vs. the current realms proposal, when communicating across the boundaries. This can account for a large number of use cases: e.g., most computation use cases, or the test framework use case (where it's just passing console.log strings across), or many of the interesting virtualization cases. Other cases will be covered by small objects or arrays, for which the structured clone overhead is quite small (less than JSON serialization and parsing). It's only the case of needing to return a large, nested object graph where there might be a noticeable performance disadvantage.

It's also worth noting that although this proposal does have a lower theoretical performance ceiling than the current realms proposal, it's probably comparable to the current realms proposal plus the associated membrane machinery that's needed to preserve encapsulation. There might be interesting tradeoffs in the large nested object graph case. There, structured cloning across the boundary means a larger up-front cost, but after that initial cost is paid, subsequent accesses throughout the large object graph are fast and well-optimized. Whereas membrane use means every access throughout the wrapped object graph incurs membrane-related overhead.

Finally, I haven't thought much about this, but you could probably get ultimate performance™ by passing in a SharedArrayBuffer and doing all communication through that.

Conclusion

I'm optimistic that this proposal removes the most dangerous feature of realms, which is that they advertise themselves as an encapsulation mechanism, but it is extremely easy to shoot oneself in the foot and break encapsulation. This encapsulated-by-default proposal would bring realms onto the same footing as other encapsulation proposals such as trusted types or private fields, and thus make it more congruent with web platform goals.

There still remains a danger with people over-using realms when they need security or performance isolation, beyond just encapsulation. This still weighs heavily on me, and its conflict with the direction the web is going (per #238) makes me still prefer not providing a realms API at all, in order to avoid such abuse. But I recognize there are cases where synchronous access to another computation environment is valuable, and I think if we curtailed the footgun-by-default nature of realms by prohibiting direct cross-realm object access, I could make peace with the proposal.

I look forward to hearing your thoughts, and hope we can meet on this "middle ground" between no realms on the web, and the current proposal.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions