sandbox: iframe host, guest bootstrap and loader routing

This commit is contained in:
Jean Chevronnet 2026-07-04 05:00:33 +00:00
parent 8e715557c9
commit 3eea1f5e76
No known key found for this signature in database
6 changed files with 313 additions and 5 deletions

118
public/plugin-sandbox.html Normal file
View file

@ -0,0 +1,118 @@
<!doctype html>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<title>Orbit sandboxed plugin</title>
<style>
html, body { margin: 0; background: transparent; color: inherit; font: inherit; }
body { font: 13px/1.3 system-ui, sans-serif; }
</style>
<body>
<script>
/*
* Guest bootstrap for a sandboxed Orbit plugin.
*
* This document runs in an OPAQUE origin (the embedding <iframe> has
* sandbox="allow-scripts" and no allow-same-origin), so it has no access to the
* app's DOM, cookies, localStorage or store. Its only link to the app is the
* MessagePort handed over in the `init` message; every privileged action is an
* RPC the host validates against the plugin's granted permissions.
*
* Kept as a static, dependency-free file (not part of the TS bundle) so it can be
* served with its own strict CSP.
*/
(function () {
var port, snap = { active: '', nick: '', account: '', buffers: [] }, store = {};
var listeners = {}; // event name -> [fn]
var pending = new Map(), rpcId = 0; // in-flight RPCs
function rpc(method) {
var args = Array.prototype.slice.call(arguments, 1);
return new Promise(function (resolve, reject) {
var id = ++rpcId;
pending.set(id, { resolve: resolve, reject: reject });
port.postMessage({ type: 'rpc', id: id, method: method, args: args });
});
}
function emit(name) {
var a = Array.prototype.slice.call(arguments, 1);
(listeners[name] || []).forEach(function (fn) { try { fn.apply(null, a); } catch (e) { /* plugin threw */ } });
}
// The constrained API a sandboxed plugin sees. Same shape spirit as the in-page
// Orbit, but privileged bits go through the host and reads come from the snapshot.
function makeApi(name) {
return {
name: name,
sandboxed: true,
log: function () { rpc.apply(null, ['log'].concat(Array.prototype.slice.call(arguments))); },
on: function (ev, fn) { (listeners[ev] = listeners[ev] || []).push(fn); return function () {
listeners[ev] = (listeners[ev] || []).filter(function (f) { return f !== fn; }); }; },
irc: {
say: function (t) { return rpc('irc.say', t); },
msg: function (to, t) { return rpc('irc.msg', to, t); },
send: function (l) { return rpc('irc.send', l); },
join: function (c) { return rpc('irc.join', c); },
part: function (c) { return rpc('irc.part', c); },
list: function () { return rpc('irc.list'); },
},
notify: function (title, body) { return rpc('notify', title, body); },
state: {
active: function () { return snap.active; },
nick: function () { return snap.nick; },
account: function () { return snap.account; },
buffers: function () { return snap.buffers.slice(); },
},
storage: {
get: function (k, fallback) { return k in store ? store[k] : fallback; },
set: function (k, v) { store[k] = v; return rpc('storage.set', k, v); },
},
// Populate this iframe's own DOM, then claim a UI slot in the app shell.
// Height is reported back so the host can size the frame to the content.
ui: function (slot, build) {
try { build(document.body); } catch (e) { rpc('log', 'ui build threw: ' + e); }
rpc('ui.claim', slot);
var report = function () { rpc('ui.resize', document.documentElement.scrollHeight); };
report();
if (window.ResizeObserver) new ResizeObserver(report).observe(document.documentElement);
},
};
}
// The global the plugin source registers against: Orbit.plugin('name', fn).
var registered = false;
window.Orbit = {
sandboxed: true,
plugin: function (name, fn) {
if (registered) return; registered = true;
var api = makeApi(name);
try { fn(api, api.log); } catch (e) { rpc('log', 'plugin threw during init: ' + e); }
},
};
window.addEventListener('message', function (e) {
var m = e.data;
if (!m || !m.type) return;
if (m.type === 'init' && e.ports && e.ports[0]) {
port = e.ports[0];
snap = m.snapshot || snap;
store = m.storage || {};
port.onmessage = function (ev) {
var d = ev.data;
if (!d) return;
if (d.type === 'rpc:reply') {
var p = pending.get(d.id); if (!p) return; pending.delete(d.id);
d.error ? p.reject(new Error(d.error)) : p.resolve(d.result);
} else if (d.type === 'event') {
emit.apply(null, [d.name].concat(d.args || []));
} else if (d.type === 'snapshot') {
snap = d.snapshot || snap;
}
};
// Run the plugin now that the bridge is live. new Function keeps it out of
// this bootstrap's scope; it only reaches the app through window.Orbit.
try { new Function(m.source).call(window); }
catch (err) { rpc('log', 'plugin source failed: ' + err); }
}
});
})();
</script>

View file

@ -0,0 +1,30 @@
/*
* Orbit sandboxed-plugin demo.
*
* Load it SANDBOXED from config.json:
* "plugins": [{ "url": "/app/plugins/orbit-sandbox-demo.js", "sandbox": true,
* "permissions": ["irc", "storage"] }]
*
* It runs in an opaque-origin iframe and can only reach the app through the
* capability bridge. With just ["irc","storage"] granted, orbit.notify() would be
* refused by the host. It has no access to the page DOM, cookies or localStorage.
*/
Orbit.plugin('sandbox-demo', function (orbit, log) {
log('sandboxed demo booted; nick=' + orbit.state.nick());
orbit.ui('footer_item', function (root) {
var btn = document.createElement('button');
btn.textContent = '🧪 wave';
btn.style.cssText = 'font:inherit;cursor:pointer;border:1px solid #8884;border-radius:8px;' +
'padding:.25rem .5rem;background:transparent;color:inherit';
btn.onclick = function () {
var n = (orbit.storage.get('waves', 0)) + 1;
orbit.storage.set('waves', n);
orbit.irc.say('👋 (' + n + ' from a sandboxed plugin)');
btn.title = n + ' waves';
};
root.appendChild(btn);
});
orbit.on('message', function (m) { log('saw a message in ' + (m && m.target)); });
});

View file

@ -72,8 +72,14 @@ export interface AppConfig {
plugins?: PluginEntry[]; plugins?: PluginEntry[];
} }
/** A plugin to load: a bare URL, or a URL with an SRI hash (and optional crossorigin). */ /** A plugin to load: a bare URL, or a URL with options.
export type PluginEntry = string | { url: string; integrity?: string; crossorigin?: string }; * `sandbox: true` runs it in an opaque-origin iframe reachable only through a
* capability-gated bridge; `permissions` lists what it may do (irc/notify/storage).
* Untrusted or community plugins should be sandboxed. Trusted first-party plugins
* may run in-page (the default) for the full React API. */
export type PluginEntry =
| string
| { url: string; integrity?: string; crossorigin?: string; sandbox?: boolean; permissions?: string[] };
export const DEFAULT_CONFIG: AppConfig = { export const DEFAULT_CONFIG: AppConfig = {
server: { url: 'wss://www.swaygo.fr/irc/', guestIdent: 'Invité' }, server: { url: 'wss://www.swaygo.fr/irc/', guestIdent: 'Invité' },

View file

@ -20,8 +20,13 @@ export function scriptAttrs(entry: PluginEntry): ScriptAttrs {
} }
export function loadPlugins(): void { export function loadPlugins(): void {
const list = (getConfig().plugins ?? []).filter(Boolean).map(scriptAttrs).filter((p) => p.url); const entries = (getConfig().plugins ?? []).filter(Boolean);
for (const { url, integrity, crossorigin } of list) { // `sandbox: true` entries run in an opaque-origin iframe via the sandbox host;
// everything else is a trusted in-page <script> (same trust as the app).
const sandboxed = entries.filter((e): e is Exclude<typeof e, string> => typeof e !== 'string' && !!e.sandbox);
const inPage = entries.filter((e) => typeof e === 'string' || !e.sandbox).map(scriptAttrs).filter((p) => p.url);
for (const { url, integrity, crossorigin } of inPage) {
const el = document.createElement('script'); const el = document.createElement('script');
el.src = url; el.src = url;
el.async = true; el.async = true;
@ -31,5 +36,14 @@ export function loadPlugins(): void {
el.onerror = () => console.error('[plugins] failed to load', url); el.onerror = () => console.error('[plugins] failed to load', url);
document.head.appendChild(el); document.head.appendChild(el);
} }
if (list.length && pluginDebug()) console.log(`[plugins] loading ${list.length} plugin(s)`, list.map((p) => p.url));
if (sandboxed.length) {
// Loaded lazily so the sandbox subsystem (and React) isn't pulled in unless used.
void import('./sandbox/host').then(({ mountSandboxedPlugin }) => {
for (const entry of sandboxed) void mountSandboxedPlugin(entry);
});
}
if ((inPage.length || sandboxed.length) && pluginDebug())
console.log(`[plugins] loading ${inPage.length} in-page, ${sandboxed.length} sandboxed`);
} }

View file

@ -0,0 +1,15 @@
import { useRef, useEffect } from 'react';
// Mounts a host-owned <iframe> into a UI slot. The iframe element is created once
// by the sandbox host and kept alive across renders — moving an iframe in the DOM
// reloads it (dropping the plugin), so we adopt the SAME node by reference here and
// hand it back to an offscreen holder on unmount instead of recreating it.
export function SandboxFrame({ iframe }: { iframe: HTMLIFrameElement }) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const host = ref.current;
if (host) host.appendChild(iframe);
return () => { if (iframe.parentNode === host) host?.removeChild(iframe); };
}, [iframe]);
return <span className="sbx-slot" ref={ref} />;
}

125
src/plugins/sandbox/host.ts Normal file
View file

@ -0,0 +1,125 @@
// Host side of the sandboxed-plugin bridge. Runs on the trusted app thread.
//
// For each sandboxed plugin entry it: fetches the plugin source (same-origin),
// spins an opaque-origin iframe (the isolation boundary), hands the source to the
// guest over a MessageChannel, then services capability-checked RPC and forwards a
// curated set of events + a state snapshot in. The guest can NEVER reach the app's
// DOM, cookies, localStorage or store directly — only through the gated calls below.
import { createElement } from 'react';
import { useChat } from '../../store';
import { pluginDebug, type PluginEntry } from '../../config';
import { bus } from '../bus';
import { usePluginRegistry, type UiSlot } from '../registry';
import { pluginNotify } from '../../services/notify';
import { SandboxFrame } from './SandboxFrame';
import {
isGranted, sanitizePermissions, FORWARDED_EVENTS,
type StateSnapshot, type GuestToHost,
} from './protocol';
// A same-origin document served with its OWN strict CSP; sandbox="allow-scripts"
// forces it into an opaque origin, so its scripts can't touch the app's origin.
const SANDBOX_DOC = `${import.meta.env.BASE_URL}plugin-sandbox.html`;
const STORE_PREFIX = 'orbit-sbx:';
function snapshot(): StateSnapshot {
const s = useChat.getState();
return { active: s.active, nick: s.nick, account: s.account, buffers: Object.keys(s.buffers) };
}
// Per-plugin namespaced storage, kept in the app's localStorage (the sandbox has none).
function loadStorage(name: string): Record<string, unknown> {
try { return JSON.parse(localStorage.getItem(STORE_PREFIX + name) || '{}'); } catch { return {}; }
}
function persistStorage(name: string, key: string, value: unknown): void {
const all = loadStorage(name);
if (value === undefined) delete all[key]; else all[key] = value;
try { localStorage.setItem(STORE_PREFIX + name, JSON.stringify(all)); } catch { /* quota */ }
}
// An offscreen holder keeps each iframe attached (so it loads and stays alive) until
// a UI slot adopts it via SandboxFrame.
let holderEl: HTMLElement | null = null;
function holder(): HTMLElement {
if (!holderEl) {
holderEl = document.createElement('div');
holderEl.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;left:-9999px';
document.body.appendChild(holderEl);
}
return holderEl;
}
export async function mountSandboxedPlugin(entry: Exclude<PluginEntry, string>): Promise<void> {
const url = entry.url;
const name = `sbx:${url.split('/').pop() || url}`;
const permissions = sanitizePermissions(entry.permissions);
let source: string;
try {
const res = await fetch(url, entry.integrity ? { integrity: entry.integrity } : undefined);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
source = await res.text();
} catch (e) { console.error(`[sandbox] ${name}: failed to fetch ${url}`, e); return; }
const iframe = document.createElement('iframe');
iframe.src = SANDBOX_DOC;
iframe.setAttribute('sandbox', 'allow-scripts'); // opaque origin — the isolation boundary
iframe.title = name;
iframe.style.cssText = 'border:0;width:100%;height:0;display:block;background:transparent';
const chan = new MessageChannel();
const port = chan.port1;
const log = (...a: unknown[]) => { if (pluginDebug()) console.log(`%c[${name}]`, 'color:#8957e5', ...a); };
let claimed = false;
let removeUi: () => void = () => {};
const impl: Record<string, (args: unknown[]) => unknown> = {
log: (a) => log(...a),
'irc.say': (a) => useChat.getState().sendInput(String(a[0] ?? '')),
'irc.msg': (a) => useChat.getState().client?.privmsg(String(a[0]), String(a[1] ?? '')),
'irc.send': (a) => useChat.getState().client?.send(String(a[0] ?? '')),
'irc.join': (a) => { const s = useChat.getState(); const c = String(a[0] ?? ''); s.client?.join(c); s.setActive(c); },
'irc.part': (a) => useChat.getState().client?.part(String(a[0] ?? '')),
'irc.list': () => useChat.getState().client?.list(),
notify: (a) => pluginNotify(String(a[0] ?? ''), a[1] != null ? String(a[1]) : undefined),
'storage.set': (a) => persistStorage(name, String(a[0]), a[1]),
'ui.claim': (a) => {
if (claimed) return; claimed = true;
const slot = String(a[0] ?? 'footer_item') as UiSlot;
removeUi = usePluginRegistry.getState().addUi(slot, name, () => createElement(SandboxFrame, { iframe }));
},
'ui.resize': (a) => { iframe.style.height = `${Math.max(0, Math.min(2000, Number(a[0]) || 0))}px`; },
};
port.onmessage = (e: MessageEvent) => {
const m = e.data as GuestToHost;
if (!m || m.type !== 'rpc' || typeof m.method !== 'string') return;
if (!isGranted(permissions, m.method)) {
log('DENIED ungranted capability', m.method);
port.postMessage({ type: 'rpc:reply', id: m.id, error: `denied: ${m.method}` });
return;
}
let result: unknown, error: string | undefined;
try { result = impl[m.method]?.(Array.isArray(m.args) ? m.args : []); }
catch (err) { error = String(err); }
port.postMessage({ type: 'rpc:reply', id: m.id, result, error });
};
// Forward only the curated events; refresh the cached snapshot when it can change.
const offs = FORWARDED_EVENTS.map((ev) =>
bus.on(ev, (...args: unknown[]) => {
port.postMessage({ type: 'event', name: ev, args });
if (ev === 'connected' || ev === 'buffer.active') port.postMessage({ type: 'snapshot', snapshot: snapshot() });
}));
void offs; // plugins live for the session; disposers kept for a future unload path
iframe.addEventListener('load', () => {
iframe.contentWindow?.postMessage(
{ type: 'init', name, permissions, source, snapshot: snapshot(), storage: loadStorage(name) },
'*', [chan.port2],
);
log('mounted');
});
holder().appendChild(iframe);
void removeUi; // referenced by the (future) unload path
}