119 lines
4.8 KiB
Markdown
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/modules/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.
|