diff --git a/addons-host.js b/addons-host.js index 11e93172..cde35c2c 100644 --- a/addons-host.js +++ b/addons-host.js @@ -369,11 +369,20 @@ class AddonHost { } if (!this._vaultLifecycle) throw new Error("vault.lifecycle unavailable (host not wired)"); }; + // Taking the master password (unlock is also an unthrottled oracle for + // it), creating the vault and locking it are for add-ons that ship with + // Theseus. Anything else asks the user through requestUnlock, where + // Theseus draws the prompt and the add-on never sees what was typed. + const firstPartyOnly = (what) => { + if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) { + throw new Error(`vault.lifecycle.${what} is reserved for built-in add-ons — use vault.requestUnlock()`); + } + }; return { status: async () => { requireCap(); return this._vaultLifecycle.status(); }, - unlock: async (pw) => { requireCap(); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); }, - setup: async (pw, seedSource) => { requireCap(); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); }, - lock: async () => { requireCap(); return this._vaultLifecycle.lock(manifest.id); }, + unlock: async (pw) => { requireCap(); firstPartyOnly("unlock"); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); }, + setup: async (pw, seedSource) => { requireCap(); firstPartyOnly("setup"); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); }, + lock: async () => { requireCap(); firstPartyOnly("lock"); return this._vaultLifecycle.lock(manifest.id); }, }; } @@ -386,6 +395,14 @@ class AddonHost { throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); } if (!this._vaultImports) throw new Error("vault.imports unavailable (host not wired)"); + // The imports store holds raw seeds and private keys and is not + // namespaced per add-on: list() and signer() hand back whatever any + // add-on stored. Until it is, it belongs to built-in add-ons only — + // a community extension with vault-derive could read the wallet's + // imported keys. + if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) { + throw new Error("vault.imports is reserved for built-in add-ons"); + } }; return { list: async () => { requireCap(); return this._vaultImports.list(); }, @@ -579,6 +596,9 @@ class AddonHost { const { manifest, folder } = active; const storageFile = path.join(this.dataDir, `${manifest.id}.json`); return { + // What this host can do beyond the documented surface, so an add-on + // can tell "the user switched it off" from "this Theseus is too old". + features: Object.freeze({ pageInjectPolicy: true }), // Metadata the add-on may want to reflect on id: manifest.id, folder, @@ -937,9 +957,27 @@ class AddonHost { // Inject scripts that apply to a tab URL — [{ id, source }]. A waiting // add-on's bridge is injected too (its source is read on first match); the // bridge's first message is what starts the add-on. + // Which pages an add-on's bridge is injected into, beyond its manifest + // patterns. The add-on keeps `{ mode: "all" | "allowed", origins: [...] }` + // under the reserved storage key "__pageInjectPolicy"; in "allowed" mode + // only the listed origins get the bridge. It is read from the store, not + // asked of the add-on, because the decision is made synchronously at + // document start and has to hold while an on-demand add-on is still + // dormant. A wallet that announces itself to every page tells every page + // the user has one; this lets the user say which sites may know. + _injectPolicyAllows(id, url) { + let policy = null; + try { policy = storeFor(path.join(this.dataDir, `${id}.json`), () => {}).get("__pageInjectPolicy", null); } + catch { return true; } + if (!policy || policy.mode !== "allowed") return true; + let origin = ""; + try { const u = new URL(String(url).replace(/^bns:\/\//i, "https://")); origin = `${u.protocol}//${u.host}`; } catch { return false; } + return Array.isArray(policy.origins) && policy.origins.includes(origin); + } injectionsFor(url) { const out = []; for (const { manifest, active, dormant } of this._enabledEntries()) { + if (!this._injectPolicyAllows(manifest.id, url)) continue; if (active) { if (active.inject && urlMatchesAny(url, active.inject.matchers)) out.push({ id: manifest.id, source: active.inject.source }); continue; @@ -957,6 +995,7 @@ class AddonHost { // Does this add-on's page-inject declaration cover the URL? Used to gate // page → add-on IPC so a non-matching page can't spoof a matching one. pageAllowed(id, url) { + if (!this._injectPolicyAllows(id, url)) return false; const active = this._active.get(id); if (active) return !!(active.inject && urlMatchesAny(url, active.inject.matchers)); const d = this._dormant.get(id);