The guest (bridge + SDK) was hand-written vanilla JS in public/plugin-sandbox.html — untyped, unlinted, and hand-duplicating protocol constants (drift risk that grew with the API). Move it to src/modules/sandbox/guest/ as TypeScript that imports the shared types from protocol.ts (rpc() is now typed against RpcMethod, so a method not wired on the host won't compile). A small Vite plugin transpiles it into plugin-sandbox.html: served on the fly in dev, emitted into the build otherwise — kept unminified so the served security-boundary code stays auditable, and inline so the response keeps its own header CSP (an external script can't use 'self' in an opaque origin). Behavior-neutral: verified clock/radio/dice mount, radio opens/plays identically, trigger centered.
5.1 KiB
The sandbox
The sandbox is a core subsystem (src/modules/sandbox/): it runs code in an
opaque-origin iframe (sandbox="allow-scripts", no allow-same-origin) that
reaches the app only through a capability-gated message bridge. Sandboxed code
cannot read the page DOM, cookies, localStorage (the SASL handoff password), or
the store — only what its permissions grant.
Two things use it:
- Built-in features (bundled) — the app mounts them itself at boot via
src/modules/sandbox/builtins.ts→mountSandboxed({ name, source, permissions }), with the source compiled into the app (?raw). They are opt-in per deployment: a built-in only mounts if its name is listed inconfig.json"builtins": [...]. Example: thedice🎲/🪙 widget (src/modules/sandbox/features/dice.js). To add one, followsrc/modules/sandbox/features/README.md. - Operator plugins (config) — a
config.jsonentry withsandbox: trueis fetched and mounted the same way. Use for community / third-party / less-trusted plugins. (Plain string entries still load in-page as a trusted<script>.)
Plugin files live under public/plugins/third/ (served at /app/plugins/third/):
"plugins": [
{ "url": "/app/plugins/third/orbit-clock.js", // sandboxed, no permissions
"sandbox": true, "permissions": [] },
{ "url": "/app/plugins/third/community-thing.js", // sandboxed, community
"sandbox": true, "permissions": ["irc", "storage"] }
]
Capabilities
A sandboxed plugin can do nothing privileged unless the operator grants the matching permission. Everything else (reading a cached state snapshot, rendering UI inside its own iframe) needs no grant because it is already contained.
| permission | unlocks |
|---|---|
none |
nothing — an explicit "zero permissions" declaration; use ["none"] instead of []. It is fail-closed: "none" wins over any other entry in the list |
irc |
orbit.irc.say/msg/join/part/list — acts as the user |
irc-raw |
orbit.irc.send(line) — raw wire access (MODE/KICK/QUIT/…); grant deliberately |
notify |
orbit.notify(title, body) |
storage |
orbit.storage.set(k, v) (namespaced, persisted host-side) |
Ungranted or unknown calls are refused host-side (fail-closed). The gate is unit
tested in src/modules/sandbox/protocol.test.ts.
The guest is typed core source in src/modules/sandbox/guest/ (the SDK lives here
too); a Vite plugin transpiles it into plugin-sandbox.html at build (served on the
fly in dev). The sandboxed API (src/modules/sandbox/host.ts + the guest):
orbit.log, orbit.on(event, fn) (forwarded connected / message /
buffer.active / status), orbit.irc.*, orbit.notify,
orbit.state.active/nick/account/buffers (from a pushed snapshot, synchronous),
orbit.server.network/isupport/hasCap/caps (also from the snapshot, synchronous —
gate cap-dependent behaviour so a plugin never 421s a lean server; no numeric()
here since the sandbox gets no raw events), orbit.storage.get/set, and
orbit.ui(slot, build) where build(el) populates the plugin's own iframe and the
host sizes the frame to the content.
The app's theme CSS vars (--bg, --ink, --accent, --muted, --border,
--accent-soft) are mirrored into the sandbox and kept in sync on theme
change, so plugin UI can use var(--accent) and look native in light + dark.
Examples: src/modules/sandbox/features/dice.js (a built-in, bundled + mounted by core) and
public/plugins/third/orbit-sandbox-demo.js (the minimal config-loaded form).
Required CSP (APPLIED 2026-07-04)
The sandbox iframe needs two nginx changes in chat-headers.conf. Both are
contained: the sandbox doc is opaque-origin, so its looser script policy cannot
touch the app.
-
Let the app embed the sandbox document — add
'self'to the appframe-src:frame-src 'self' https://challenges.cloudflare.com https://www.youtube-nocookie.com; -
Serve
/app/plugin-sandbox.htmlwith its own tight CSP that allows the bootstrap to run andevalplugin code, but blocks all network egress (so even a malicious sandboxed plugin cannot phone home — it may only talk over the postMessage bridge):location = /app/plugin-sandbox.html { add_header Content-Security-Policy "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; media-src https:; connect-src 'none'; frame-ancestors 'self'" always; }media-src https:lets a sandboxed plugin play an audio/video stream in its own iframe (e.g. theorbit-radiofooter player). Playback stays contained — the iframe still can'tfetch, read the response, or reach the app. Drop it if no deployment plugin needs media.
Applied live in csp-app.conf / chat-headers.conf (added 'self' to frame-src)
and a location = /app/plugin-sandbox.html block in tchatou-app-backend.conf.
Note: nginx reload cannot rekey a shared-memory zone, so activating this needed a
full systemctl restart nginx (an unrelated pre-existing limit_req key change had
been blocking reloads).