docs: add The sandbox page (built-ins + sandboxed plugins)
This commit is contained in:
parent
44771bcdee
commit
0eb797cf84
3 changed files with 127 additions and 2 deletions
|
|
@ -37,7 +37,8 @@ Merge rules: objects merge key-by-key; **arrays and scalars replace** wholesale.
|
||||||
| `features.push` | bool | Web Push notifications — the Settings row **and** the re-subscribe-on-connect. |
|
| `features.push` | bool | Web Push notifications — the Settings row **and** the re-subscribe-on-connect. |
|
||||||
| `features.imageUpload` | bool | Composer image button **and** paste / drag-drop upload. |
|
| `features.imageUpload` | bool | Composer image button **and** paste / drag-drop upload. |
|
||||||
| `features.register` | bool | Account self-service: the "Create account" button, the "Forgot password" link, and their FAQ entries. |
|
| `features.register` | bool | Account self-service: the "Create account" button, the "Forgot password" link, and their FAQ entries. |
|
||||||
| `plugins` | string[] | Plugin script URLs loaded at startup. See [Plugins](https://orbit.tchatou.fr/docs/plugins/). |
|
| `plugins` | string[] | Plugin script URLs loaded at startup — a bare URL runs in-page, or `{url, sandbox: true, permissions: […]}` runs isolated. See [Plugins](https://orbit.tchatou.fr/docs/plugins/). |
|
||||||
|
| `builtins` | string[] | Built-in **sandboxed** features to enable by name (opt-in; default `[]`). Currently `"dice"`. See [The sandbox](https://orbit.tchatou.fr/docs/sandbox/). |
|
||||||
|
|
||||||
> `defaults.*` only seed a user's preferences the **first** time — once someone changes a setting,
|
> `defaults.*` only seed a user's preferences the **first** time — once someone changes a setting,
|
||||||
> it's stored in their browser and the config no longer overrides it.
|
> it's stored in their browser and the config no longer overrides it.
|
||||||
|
|
@ -61,6 +62,7 @@ can still switch in **Settings**). The bundled plugins are localized too — see
|
||||||
"report": { "service": "ReportServ", "target": "#staff" },
|
"report": { "service": "ReportServ", "target": "#staff" },
|
||||||
"defaults": { "theme": "dark", "compact": true, "lang": "" },
|
"defaults": { "theme": "dark", "compact": true, "lang": "" },
|
||||||
"features": { "push": true, "imageUpload": true, "register": false },
|
"features": { "push": true, "imageUpload": true, "register": false },
|
||||||
"plugins": []
|
"plugins": [],
|
||||||
|
"builtins": []
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -169,3 +169,7 @@ they run with the same trust as the app itself. There is no user-uploaded plugin
|
||||||
mechanism. Orbit deliberately does **not** expose internal modules or runtime
|
mechanism. Orbit deliberately does **not** expose internal modules or runtime
|
||||||
component replacement — that would couple plugins to internals that are still
|
component replacement — that would couple plugins to internals that are still
|
||||||
moving. The API above is the stable surface.
|
moving. The API above is the stable surface.
|
||||||
|
|
||||||
|
For code you don't fully trust — a community or third-party plugin — mark the
|
||||||
|
entry `sandbox: true` to run it in an isolated, capability-gated iframe instead.
|
||||||
|
See [The sandbox](https://orbit.tchatou.fr/docs/sandbox/).
|
||||||
|
|
|
||||||
119
docs/sandbox.md
Normal file
119
docs/sandbox.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue