diff --git a/addons-host.js b/addons-host.js index 352d031e..d04169bd 100644 --- a/addons-host.js +++ b/addons-host.js @@ -30,6 +30,46 @@ const path = require("node:path"); // How long a call waits for an async activate() to settle before it is // delivered anyway. const READY_WAIT_MS = 5000; +// What a sandboxed (community) extension may ask of the host. Each name is a +// path on the api object _makeApi builds, so its capability check still runs. +// Absent on purpose: require/import (host modules), registerRequestFilter and +// registerSiteRoute (take a function), vault.imports / vault.pin and the +// lifecycle calls that handle the master password, and storage (the store is +// handed over at start and written through its own message). +const SANDBOX_API = new Set([ + "emit", "registerSidebarPanel", "revealSidebar", "openTab", "openSettings", "setSessionProxy", + "captureTab", "saveCapture", "scanActiveTabForUris", "approvalModal", + "checkAndStageSelfUpdate", "applySelfUpdate", "restartApp", "startAtLaunch", "whenUiReady", + "tabs.active", "vault.derive", "vault.requestUnlock", "vault.lifecycle.status", +]); +// Messages cross the process boundary as JSON (the binary 'advanced' channel +// format is tied to the exact V8 build on both ends). Bytes and BigInts are +// tagged so they arrive as what they were. Same pair as in the runner. +function wireEnc(v, depth = 0) { + if (depth > 40) throw new Error("value too deeply nested"); + if (v === null || v === undefined) return v === undefined ? undefined : null; + if (typeof v === "bigint") return { __wire: "big", v: v.toString() }; + if (typeof v === "function" || typeof v === "symbol") return undefined; + if (typeof v !== "object") return v; + if (v instanceof Uint8Array) return { __wire: "u8", v: Buffer.from(v.buffer, v.byteOffset, v.byteLength).toString("base64") }; + if (v instanceof ArrayBuffer) return { __wire: "u8", v: Buffer.from(v).toString("base64") }; + if (Array.isArray(v)) return v.map((x) => { const e = wireEnc(x, depth + 1); return e === undefined ? null : e; }); + if (v instanceof Date) return v.toISOString(); + const out = {}; + for (const k of Object.keys(v)) { const e = wireEnc(v[k], depth + 1); if (e !== undefined) out[k] = e; } + return out; +} +function wireDec(v) { + if (v === null || typeof v !== "object") return v; + if (Array.isArray(v)) return v.map(wireDec); + if (v.__wire === "u8" && typeof v.v === "string") return new Uint8Array(Buffer.from(v.v, "base64")); + if (v.__wire === "big" && typeof v.v === "string") return BigInt(v.v); + const out = {}; + for (const k of Object.keys(v)) out[k] = wireDec(v[k]); + return out; +} +const SANDBOX_CALL_MS = 120000; +const SANDBOX_STORE_VALUE_MAX = 4 * 1024 * 1024; // Extension points the framework understands. Extending this list means also // teaching main.js and (typically) the chrome renderer about the new point. @@ -323,6 +363,10 @@ class AddonHost { // isFirstPartyId(id) / isReservedId(id): which ids ship inside Theseus, // and which legacy ids those absorb. Gate `absorbs` in vault.derive. this._isFirstPartyId = typeof arguments[0].isFirstPartyId === "function" ? arguments[0].isFirstPartyId : null; + // Extensions that are not built in run in a sandboxed process unless the + // host explicitly opts out (sandboxExecPath overrides the binary; tests). + this._sandboxCommunity = arguments[0].sandboxCommunity !== false; + this._sandboxExecPath = typeof arguments[0].sandboxExecPath === "string" ? arguments[0].sandboxExecPath : null; this._isReservedId = typeof arguments[0].isReservedId === "function" ? arguments[0].isReservedId : null; this._approvalModal = typeof approvalModal === "function" ? approvalModal : null; this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null; @@ -589,7 +633,10 @@ class AddonHost { // resolver. This is where the trust decision lives: we're loading arbitrary // JS into the main process with full API access. let mod; - try { + const holder = { active: null }; + const sandboxed = this._shouldSandbox(manifest); + if (sandboxed) mod = this._sandboxModule(manifest, folder, mainPath, holder); + else try { // Bust the require cache so a manual reload picks up edits — cheap since // add-ons are small. When the version changed (a hot-applied update) the // whole folder goes, or the new index.js would run against the previous @@ -608,7 +655,8 @@ class AddonHost { throw new Error(`main file must export an activate(api) function`); } // Declared panels are registered up front; activate() may still add more. - const active = { manifest, folder, exports: mod, sidebarPanels: this._resolvePanels(manifest, folder), handlers: new Map(), inject: null, tabListeners: [], siteRoutes: new Map(), ready: Promise.resolve() }; + const active = { manifest, folder, exports: mod, sandboxed, sidebarPanels: this._resolvePanels(manifest, folder), handlers: new Map(), inject: null, tabListeners: [], siteRoutes: new Map(), ready: Promise.resolve() }; + holder.active = active; if (manifest.pageInject) { // Read the inject source once at activation. It's shipped to every // matching tab's preload verbatim, so a syntax error surfaces in the @@ -644,6 +692,139 @@ class AddonHost { this.log(`activated ${manifest.id} v${manifest.version}`); } + // ---- sandbox for extensions that do not ship with Theseus ----------------- + // A built-in add-on is code we publish and it runs in this process. Anything + // else — a community extension from the catalog — runs in its own process + // under Node's permission model (see lib/addon-sandbox-runner.cjs) and + // reaches the browser only through the allow-listed calls below. In this + // process an extension could load `electron`, watch the unlock prompt, and + // read the vault, the wallet's store and every tab, whatever its manifest + // said. If the sandbox cannot be started the extension does not run. + _shouldSandbox(manifest) { + return this._sandboxCommunity && !!this._isFirstPartyId && !this._isFirstPartyId(manifest.id); + } + _sandboxRunnerFile() { + if (this._runnerFile) return this._runnerFile; + // Copied out to a real file each launch: the packaged app keeps lib/ + // inside app.asar, which a process in Node mode under the permission + // model cannot be pointed at. + const src = fs.readFileSync(path.join(__dirname, "lib", "addon-sandbox-runner.cjs"), "utf8"); + const dir = path.join(this.dataDir, ".sandbox"); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, "runner.cjs"); + fs.writeFileSync(file, src); + return (this._runnerFile = file); + } + _sandboxModule(manifest, folder, mainPath, holder) { + const { fork } = require("node:child_process"); + const id = manifest.id; + const log = (...a) => this.log(`[${id}]`, ...a); + let child = null, nextId = 0, activated = null; + const waiting = new Map(); // call id -> { resolve, reject, timer } + const callScopes = new Map(); // call id -> pageCallCtx store while a page call is in flight + const failAll = (why) => { for (const w of waiting.values()) { clearTimeout(w.timer); w.reject(new Error(why)); } waiting.clear(); callScopes.clear(); }; + const send = (m) => { try { if (child && child.connected) child.send(wireEnc(m)); } catch {} }; + const errOut = (e) => ({ message: String((e && e.message) || e), code: e && e.code != null ? e.code : undefined }); + + const activate = (api) => new Promise((resolve, reject) => { + const runner = this._sandboxRunnerFile(); + const scratch = path.join(this.dataDir, ".sandbox", "scratch", id); + fs.mkdirSync(scratch, { recursive: true }); + const env = { ELECTRON_RUN_AS_NODE: "1" }; + for (const k of ["SystemRoot", "windir", "TEMP", "TMP", "LANG", "TZ"]) if (process.env[k]) env[k] = process.env[k]; + try { + child = fork(runner, [], { + execPath: this._sandboxExecPath || process.execPath, + execArgv: ["--permission", `--allow-fs-read=${runner}`, `--allow-fs-read=${folder}`, `--allow-fs-read=${scratch}`, `--allow-fs-write=${scratch}`], + cwd: folder, env, serialization: "json", windowsHide: true, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }); + } catch (e) { reject(new Error(`sandbox could not start: ${e?.message || e}`)); return; } + activated = { resolve, reject }; + const startTimer = setTimeout(() => { if (activated) { activated = null; try { child.kill(); } catch {} reject(new Error("sandbox did not answer")); } }, 20000); + if (startTimer.unref) startTimer.unref(); + let tabsOff = null; + child.on("exit", (code) => { + clearTimeout(startTimer); + try { if (tabsOff) tabsOff(); } catch {} + failAll("the extension's process ended"); + if (activated) { activated.reject(new Error(`sandbox exited before activation (code ${code})`)); activated = null; } + else if (holder.active && this._active.get(id) === holder.active) log(`sandboxed process exited (code ${code})`); + }); + child.on("error", (e) => log("sandbox error:", e?.message || e)); + child.on("message", async (raw) => { + if (!raw || typeof raw !== "object") return; + const m = wireDec(raw); + const active = holder.active; + if (m.t === "ready") { + let store = {}; + try { store = api.storage.all() || {}; } catch {} + send({ t: "activate", manifest: { id, name: manifest.name, version: manifest.version, capabilities: manifest.capabilities }, folder, mainPath, scratchDir: scratch, store, features: api.features }); + } else if (m.t === "activated") { + clearTimeout(startTimer); + const a = activated; activated = null; + if (!a) return; + if (m.ok) a.resolve(); else a.reject(new Error(m.error?.message || "activate() failed")); + } else if (m.t === "handler") { + if (!active || typeof m.name !== "string" || !m.name) return; + // A stub in this process forwards each call to the handler that + // lives in the extension's process. + active.handlers.set(m.name, (payload, ctx) => new Promise((res, rej) => { + const cid = ++nextId; + const scope = pageCallCtx.getStore(); + if (scope) callScopes.set(cid, scope); + const timer = setTimeout(() => { waiting.delete(cid); callScopes.delete(cid); rej(new Error(`"${m.name}" timed out`)); }, SANDBOX_CALL_MS); + if (timer.unref) timer.unref(); + waiting.set(cid, { resolve: res, reject: rej, timer }); + send({ t: "call", id: cid, name: m.name, payload, ctx: ctx ? { from: ctx.from, origin: ctx.origin, tabId: ctx.tabId } : {} }); + })); + } else if (m.t === "res") { + const w = waiting.get(m.id); + if (!w) return; + waiting.delete(m.id); callScopes.delete(m.id); clearTimeout(w.timer); + if (m.ok) w.resolve(m.value); + else { const e = new Error(m.error?.message || "extension call failed"); if (m.error?.code != null) e.code = m.error.code; w.reject(e); } + } else if (m.t === "api") { + // The only way the extension reaches the browser. Not on the list: + // not callable. On the list: the same function, with the same + // capability checks, an in-process add-on would have called. + let out; + try { + if (typeof m.path !== "string" || !SANDBOX_API.has(m.path)) throw new Error(`"${m.path}" is not available to sandboxed extensions`); + const fn = m.path.split(".").reduce((o, k) => (o ? o[k] : undefined), api); + if (typeof fn !== "function") throw new Error(`"${m.path}" is not available`); + const args = Array.isArray(m.args) ? m.args : []; + const scope = m.callId != null ? callScopes.get(m.callId) : null; + const value = await (scope ? pageCallCtx.run(scope, () => fn(...args)) : fn(...args)); + out = { t: "res", id: m.id, ok: true, value: value === undefined ? null : value }; + } catch (e) { out = { t: "res", id: m.id, ok: false, error: errOut(e) }; } + try { send(out); } catch { send({ t: "res", id: m.id, ok: false, error: { message: "result could not be passed to the extension" } }); } + } else if (m.t === "storage.set") { + try { + const size = m.value == null ? 0 : JSON.stringify(m.value).length; + if (size > SANDBOX_STORE_VALUE_MAX) throw new Error("value too large"); + if (String(m.key).startsWith("__") && m.key !== "__pageInjectPolicy") throw new Error("reserved key"); + api.storage.set(String(m.key), m.value == null ? null : m.value); + } catch (e) { log(`storage.set("${m.key}") refused: ${e?.message || e}`); } + } else if (m.t === "log") { + log(...(Array.isArray(m.args) ? m.args.map((x) => String(x).slice(0, 2000)) : [])); + } else if (m.t === "tabs.watch") { + if (tabsOff) return; + try { tabsOff = api.tabs.onChange((t) => send({ t: "tabs", data: t })); } catch (e) { log("tabs.onChange refused:", e?.message || e); } + } + }); + }); + const deactivate = () => { + failAll("the extension was stopped"); + const c = child; child = null; + if (!c) return; + try { if (c.connected) c.send(wireEnc({ t: "deactivate" })); } catch {} + const t = setTimeout(() => { try { c.kill(); } catch {} }, 1500); + if (t.unref) t.unref(); + }; + return { activate, deactivate }; + } + // Tear down every active add-on before a re-discover so long-lived state // (sockets, timers) from a previous activation doesn't pile up. _deactivateAll() { diff --git a/lib/addon-sandbox-runner.cjs b/lib/addon-sandbox-runner.cjs new file mode 100644 index 00000000..b08916f7 --- /dev/null +++ b/lib/addon-sandbox-runner.cjs @@ -0,0 +1,181 @@ +// Runs ONE community extension outside the browser process. +// +// An extension used to be require()d into Electron's main process, where it +// could load `electron`, hook the unlock prompt, read the vault files and +// the wallet's store, or shell out to the OS keystore — its declared +// capabilities only bound an honest extension. Extensions that do not ship +// with Theseus now run here instead: a separate process started in Node mode +// under Node's permission model, allowed to read nothing but its own folder +// (and write nothing but its own scratch folder), with no child processes, +// no workers, no native add-ons and no Electron. Everything it can do to the +// browser goes through the message channel below, and the host answers only +// the calls on its allow-list, with the same capability checks as before. +// +// The `api` object keeps the in-process shape where it can. Differences an +// extension author will meet: +// - host calls return promises (tabs.active() included); +// - api.require / api.import are gone — bundle your dependencies; +// - registerRequestFilter and registerSiteRoute are not offered (both hand +// the host a function it would have to call synchronously); +// - vault.imports, vault.pin and vault.lifecycle.unlock/setup/lock are +// built-in only, as they already were. +"use strict"; +const { AsyncLocalStorage } = require("node:async_hooks"); +const callScope = new AsyncLocalStorage(); // which page call a host request belongs to + +let seq = 0; +const pending = new Map(); // id -> { resolve, reject } +const handlers = new Map(); // message name -> fn(payload, ctx) +const tabListeners = new Set(); +let mod = null; + +// Messages cross the process boundary as JSON (the binary 'advanced' channel +// format is tied to the exact V8 build on both ends). Bytes and BigInts are +// tagged so they arrive as what they were. +function wireEnc(v, depth = 0) { + if (depth > 40) throw new Error("value too deeply nested"); + if (v === null || v === undefined) return v === undefined ? undefined : null; + if (typeof v === "bigint") return { __wire: "big", v: v.toString() }; + if (typeof v === "function" || typeof v === "symbol") return undefined; + if (typeof v !== "object") return v; + if (v instanceof Uint8Array) return { __wire: "u8", v: Buffer.from(v.buffer, v.byteOffset, v.byteLength).toString("base64") }; + if (v instanceof ArrayBuffer) return { __wire: "u8", v: Buffer.from(v).toString("base64") }; + if (Array.isArray(v)) return v.map((x) => { const e = wireEnc(x, depth + 1); return e === undefined ? null : e; }); + if (v instanceof Date) return v.toISOString(); + const out = {}; + for (const k of Object.keys(v)) { const e = wireEnc(v[k], depth + 1); if (e !== undefined) out[k] = e; } + return out; +} +function wireDec(v) { + if (v === null || typeof v !== "object") return v; + if (Array.isArray(v)) return v.map(wireDec); + if (v.__wire === "u8" && typeof v.v === "string") return new Uint8Array(Buffer.from(v.v, "base64")); + if (v.__wire === "big" && typeof v.v === "string") return BigInt(v.v); + const out = {}; + for (const k of Object.keys(v)) out[k] = wireDec(v[k]); + return out; +} +const send = (m) => { try { process.send(wireEnc(m)); } catch { /* host is gone */ } }; +const errOut = (e) => ({ message: String((e && e.message) || e), code: e && e.code != null ? e.code : undefined }); +const plain = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v))); + +function hostCall(path, args) { + return new Promise((resolve, reject) => { + const id = ++seq; + pending.set(id, { resolve, reject }); + const scope = callScope.getStore(); + send({ t: "api", id, path, args, callId: scope ? scope.callId : null }); + }); +} +const builtInOnly = (what) => () => Promise.reject(new Error(`${what} is reserved for built-in add-ons`)); +const notOffered = (what, why) => () => { throw new Error(`${what} is not available to sandboxed extensions — ${why}`); }; + +function makeApi(init) { + const store = init.store && typeof init.store === "object" ? init.store : {}; + const call = (path) => (...args) => hostCall(path, args); + return { + id: init.manifest.id, + folder: init.folder, + dataDir: init.scratchDir, + features: Object.freeze({ ...(init.features || {}), sandboxed: true }), + log: (...a) => send({ t: "log", args: a.map((x) => (typeof x === "string" ? x : (() => { try { return JSON.stringify(x); } catch { return String(x); } })())) }), + // The store is this extension's own key/value file. It is handed over + // whole at start and written through, so get() stays synchronous. + storage: { + get: (key, fallback = null) => (Object.prototype.hasOwnProperty.call(store, key) && store[key] != null ? plain(store[key]) : fallback), + set: (key, value) => { + const k = String(key); + if (value === null || value === undefined) delete store[k]; else store[k] = plain(value); + send({ t: "storage.set", key: k, value: value === undefined ? null : plain(value) }); + }, + all: () => plain(store), + }, + onMessage: (name, fn) => { + if (typeof name !== "string" || !name || typeof fn !== "function") throw new Error("onMessage needs (name, fn)"); + handlers.set(name, fn); + send({ t: "handler", name }); + }, + emit: (msg, payload) => { hostCall("emit", [String(msg), payload]).catch(() => {}); }, + registerSidebarPanel: (spec) => { hostCall("registerSidebarPanel", [spec]).catch((e) => send({ t: "log", args: ["registerSidebarPanel: " + e.message] })); }, + revealSidebar: (panelId) => { hostCall("revealSidebar", [panelId]).catch(() => {}); }, + openTab: call("openTab"), + openSettings: call("openSettings"), + setSessionProxy: call("setSessionProxy"), + captureTab: call("captureTab"), + saveCapture: call("saveCapture"), + scanActiveTabForUris: call("scanActiveTabForUris"), + approvalModal: call("approvalModal"), + checkAndStageSelfUpdate: call("checkAndStageSelfUpdate"), + applySelfUpdate: call("applySelfUpdate"), + restartApp: call("restartApp"), + startAtLaunch: (on) => { hostCall("startAtLaunch", [!!on]).catch(() => {}); }, + whenUiReady: call("whenUiReady"), + tabs: { + active: call("tabs.active"), + onChange: (cb) => { + if (typeof cb !== "function") return () => {}; + tabListeners.add(cb); + send({ t: "tabs.watch" }); + return () => tabListeners.delete(cb); + }, + }, + vault: { + derive: call("vault.derive"), + requestUnlock: call("vault.requestUnlock"), + lifecycle: { + status: call("vault.lifecycle.status"), + unlock: builtInOnly("vault.lifecycle.unlock"), + setup: builtInOnly("vault.lifecycle.setup"), + lock: builtInOnly("vault.lifecycle.lock"), + }, + imports: { list: builtInOnly("vault.imports"), add: builtInOnly("vault.imports"), remove: builtInOnly("vault.imports"), signer: builtInOnly("vault.imports") }, + pin: { status: builtInOnly("vault.pin"), unlock: builtInOnly("vault.pin"), set: builtInOnly("vault.pin"), clear: builtInOnly("vault.pin") }, + }, + registerRequestFilter: notOffered("registerRequestFilter", "the host would have to call into the extension synchronously for every request"), + registerSiteRoute: notOffered("registerSiteRoute", "site routes are for built-in add-ons"), + require: notOffered("api.require", "bundle the modules you need inside the extension folder"), + import: notOffered("api.import", "bundle the modules you need inside the extension folder"), + }; +} + +process.on("message", async (raw) => { + if (!raw || typeof raw !== "object") return; + const m = wireDec(raw); + if (m.t === "res") { + const p = pending.get(m.id); + if (!p) return; + pending.delete(m.id); + if (m.ok) p.resolve(m.value); + else { const e = new Error(m.error && m.error.message || "host call failed"); if (m.error && m.error.code != null) e.code = m.error.code; p.reject(e); } + return; + } + if (m.t === "activate") { + try { + mod = require(m.mainPath); + if (!mod || typeof mod.activate !== "function") throw new Error("main file must export an activate(api) function"); + await mod.activate(makeApi(m)); + send({ t: "activated", ok: true }); + } catch (e) { send({ t: "activated", ok: false, error: errOut(e) }); } + return; + } + if (m.t === "call") { + const fn = handlers.get(m.name); + if (!fn) { send({ t: "res", id: m.id, ok: false, error: { message: `no handler for "${m.name}"` } }); return; } + try { + const value = await callScope.run({ callId: m.id }, () => fn(m.payload, m.ctx || {})); + send({ t: "res", id: m.id, ok: true, value: value === undefined ? null : value }); + } catch (e) { send({ t: "res", id: m.id, ok: false, error: errOut(e) }); } + return; + } + if (m.t === "tabs") { for (const cb of tabListeners) { try { cb(m.data); } catch {} } return; } + if (m.t === "deactivate") { + try { if (mod && typeof mod.deactivate === "function") await mod.deactivate(); } catch {} + process.exit(0); + } +}); +// An extension's stray rejection or exception must not take its process down +// silently — say so in the host log and keep serving. +process.on("uncaughtException", (e) => send({ t: "log", args: ["uncaught exception: " + ((e && e.stack) || e)] })); +process.on("unhandledRejection", (e) => send({ t: "log", args: ["unhandled rejection: " + ((e && e.message) || e)] })); +process.on("disconnect", () => process.exit(0)); +send({ t: "ready" });