sandbox: iframe host, guest bootstrap and loader routing
This commit is contained in:
parent
8e715557c9
commit
3eea1f5e76
6 changed files with 313 additions and 5 deletions
118
public/plugin-sandbox.html
Normal file
118
public/plugin-sandbox.html
Normal 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>
|
||||||
30
public/plugins/orbit-sandbox-demo.js
Normal file
30
public/plugins/orbit-sandbox-demo.js
Normal 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)); });
|
||||||
|
});
|
||||||
|
|
@ -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é' },
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
src/plugins/sandbox/SandboxFrame.tsx
Normal file
15
src/plugins/sandbox/SandboxFrame.tsx
Normal 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
125
src/plugins/sandbox/host.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue