orbit/docs/SANDBOX.md
Jean 96728bb9ea sandbox: move the guest bootstrap into typed core source
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.
2026-07-07 18:58:06 +00:00

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.tsmountSandboxed({ 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 in config.json "builtins": [...]. Example: the dice 🎲/🪙 widget (src/modules/sandbox/features/dice.js). To add one, follow src/modules/sandbox/features/README.md.
  • Operator plugins (config) — a config.json entry with sandbox: true is 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.

  1. Let the app embed the sandbox document — add 'self' to the app frame-src:

    frame-src 'self' https://challenges.cloudflare.com https://www.youtube-nocookie.com;
    
  2. Serve /app/plugin-sandbox.html with its own tight CSP that allows the bootstrap to run and eval plugin 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. the orbit-radio footer player). Playback stays contained — the iframe still can't fetch, 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).