orbit-client-docs/docs/sandbox.md

119 lines
4.8 KiB
Markdown

Section: Plugins
Order: 220
# The sandbox
Most plugins run **in-page** — a trusted `<script>` on the app origin with the
full `Orbit` API. That is fine for code an operator wrote or fully vets, but it
also means such a plugin can read anything on the page: cookies, `localStorage`
(including the SASL handoff password), the store. For code you **don't** fully
trust — a community or third-party plugin — Orbit has a real isolation boundary.
The **sandbox** runs code in an **opaque-origin iframe**
(`sandbox="allow-scripts"`, no `allow-same-origin`) that can reach the app **only**
through a capability-gated message bridge. Sandboxed code cannot touch the page
DOM, cookies, `localStorage`, or the store — it can do only what its declared
`permissions` allow, and nothing else.
> **Experimental.** The sandbox API is small and may change between releases. It
> is a core subsystem (`src/sandbox/`), not a plugin itself.
## Two ways it is used
### Built-in features
Orbit ships a few small features that run **sandboxed by default** — bundled into
the app and mounted by the app itself, no external file to load. They are
**opt-in per deployment**: a built-in only mounts if its name is listed in
`config.json`.
```jsonc
{
"builtins": ["dice"] // enables the sandboxed 🎲 / 🪙 footer widget
}
```
The default is `[]` (no built-ins). Currently the only built-in is `dice`.
### Sandboxed plugins
Any `config.json` plugin entry can opt into the sandbox with `sandbox: true` and a
list of `permissions`. Use this for community / third-party / less-trusted plugins.
```jsonc
"plugins": [
"/app/plugins/orbit-clock.js", // in-page (trusted)
{ "url": "/app/plugins/community-thing.js", // sandboxed (isolated)
"sandbox": true, "permissions": ["irc", "storage"] }
]
```
## Capabilities
A sandboxed plugin can do nothing privileged unless the operator grants the
matching permission. Reading a cached state snapshot and rendering UI inside its
own iframe need no grant — they are already contained.
| permission | unlocks |
|---|---|
| `irc` | `orbit.irc.say / msg / send / join / part / list` — acts as the user |
| `notify` | `orbit.notify(title, body)` |
| `storage` | `orbit.storage.set(key, value)` (namespaced, persisted for the plugin) |
Ungranted or unknown calls are refused by the host (fail-closed).
## The sandboxed API
A sandboxed plugin registers the same way as any plugin —
`Orbit.plugin(name, fn)` — but `fn` receives a **constrained** `orbit`:
| member | notes |
|---|---|
| `orbit.log(...)` | namespaced console log |
| `orbit.on(event, fn)` | forwarded events: `connected`, `message`, `buffer.active`, `status` |
| `orbit.irc.*` | `say / msg / send / join / part / list` (needs `irc`) |
| `orbit.notify(title, body?)` | desktop notification (needs `notify`) |
| `orbit.state.active() / nick() / account() / buffers()` | synchronous, from a pushed snapshot |
| `orbit.storage.get(k, fallback?) / set(k, v)` | `set` needs `storage` |
| `orbit.ui(slot, build)` | `build(el)` fills the plugin's own iframe; the host sizes the frame to the content |
The app's theme variables (`--bg`, `--ink`, `--accent`, `--muted`, `--border`,
`--panel`, `--green-soft`) are mirrored into the sandbox and kept in sync on theme
change, so sandboxed UI can use `var(--accent)` and look native in light and dark.
## Writing one
```js
Orbit.plugin('coinflip', function (orbit) {
orbit.ui('footer_item', function (root) {
var b = document.createElement('button');
b.textContent = '🪙';
b.style.cssText = 'border:1px solid var(--border);background:var(--panel);' +
'color:var(--ink);border-radius:9px;padding:.3rem .5rem;cursor:pointer';
b.onclick = function () { orbit.irc.say('🪙 ' + (Math.random() < 0.5 ? 'heads' : 'tails')); };
root.appendChild(b);
});
});
```
With `"permissions": ["irc"]` this can post to the channel but has no access to the
page, cookies, `localStorage`, or the store. UI is built with plain DOM inside the
plugin's own iframe — the app's React is not shared across the isolation boundary.
## Self-hosting
The sandbox iframe needs two things from your web server, both contained (the
sandbox document is opaque-origin, so its looser script policy cannot touch the
app):
1. Add `'self'` to the app's `frame-src` in its Content-Security-Policy, so the app
may embed the sandbox document.
2. Serve `/app/plugin-sandbox.html` with its **own** tight CSP that lets the
bootstrap run but blocks all network egress:
```
Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; frame-ancestors 'self'
```
Until (1) is applied the sandbox iframe is blocked, so sandboxed code is simply
inert — in-page plugins are unaffected.