diff --git a/addons-host.js b/addons-host.js index a0703a24..11e93172 100644 --- a/addons-host.js +++ b/addons-host.js @@ -293,6 +293,10 @@ class AddonHost { // vaultRequestUnlock: Theseus shows its own PIN / master-password prompt // and resolves { ok } — the add-on never sees what the user typed. this._vaultRequestUnlock = typeof arguments[0].vaultRequestUnlock === "function" ? arguments[0].vaultRequestUnlock : null; + // 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; + 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; this._scanTabForUris = typeof scanTabForUris === "function" ? scanTabForUris : null; @@ -788,7 +792,18 @@ class AddonHost { if (/[^a-z0-9/._-]/i.test(p) || p.includes("..")) { throw new Error(`vault.derive: purposePath must look like "${manifest.id}/"`); } - const allowed = [manifest.id, ...(manifest.absorbs || [])]; + // `absorbs` hands one add-on another add-on's key namespace, so it + // is honoured only for first-party (bundled) ids. A community + // install has it stripped, but an UPDATE of one is placed as + // shipped — version 2 could declare absorbs:["aegis"] and derive + // the wallet's keys. And an id a first-party add-on absorbs + // ("bchwallet", "siawallet") is that add-on's namespace: nothing + // else may derive under it, even if it got installed under the id. + const firstParty = this._isFirstPartyId ? !!this._isFirstPartyId(manifest.id) : true; + if (!firstParty && this._isReservedId && this._isReservedId(manifest.id)) { + throw new Error(`vault.derive: the id "${manifest.id}" is reserved by a built-in add-on`); + } + const allowed = [manifest.id, ...(firstParty ? (manifest.absorbs || []) : [])]; if (!allowed.some((prefix) => p.startsWith(prefix + "/"))) { const list = allowed.length > 1 ? `one of "${allowed.join('", "')}"` diff --git a/approval.html b/approval.html index 6f56703d..87cf6540 100644 --- a/approval.html +++ b/approval.html @@ -82,8 +82,20 @@ `; const mask = document.querySelector(".promptmask"); mask.addEventListener("mousedown", (e) => { if (e.target === mask) finish("cancel", false); }); + // Nothing but Cancel works for the first moment. A page can raise this + // overlay without a gesture and knows where the primary button will be, + // so "double-click here" put the second click on Send. A click has to + // arrive after the dialog has been on screen long enough to be seen. + const ARM_MS = 800; + const armAt = Date.now() + ARM_MS; + const acts = Array.from(document.querySelectorAll('button[data-id]')).filter((b) => b.dataset.id !== "cancel"); + acts.forEach((b) => { b.disabled = true; b.style.opacity = "0.45"; }); + setTimeout(() => { if (current === req) acts.forEach((b) => { b.disabled = false; b.style.opacity = ""; }); }, ARM_MS); document.querySelectorAll("button[data-id]").forEach((b) => { - b.addEventListener("click", () => finish(b.dataset.id, !!document.getElementById("chk")?.checked)); + b.addEventListener("click", () => { + if (b.dataset.id !== "cancel" && Date.now() < armAt) return; + finish(b.dataset.id, !!document.getElementById("chk")?.checked); + }); }); // Focus the non-destructive default so Enter never blindly approves a // spend; the user has to tab or click onto the primary action. diff --git a/main.js b/main.js index 525134e7..d45a9270 100644 --- a/main.js +++ b/main.js @@ -2201,6 +2201,28 @@ function migrateExtensionDirs() { } } function bundledAddonsDir() { return path.join(RES_DIR, "bundled-addons"); } +// Ids that ship inside Theseus, and the legacy ids those add-ons absorb +// (Aegis absorbs "bchwallet" and "siawallet": the wallet's key namespace). +// Read once from the bundled manifests — the bundle cannot change while the +// app runs. Used to keep `absorbs` first-party only and to keep community +// extensions off both sets of ids. +let firstPartyIdsCache = null; +function firstPartyAddonIds() { + if (firstPartyIdsCache) return firstPartyIdsCache; + const bundled = new Set(), absorbed = new Set(); + try { + for (const de of fs.readdirSync(bundledAddonsDir(), { withFileTypes: true })) { + if (!de.isDirectory()) continue; + try { + const m = JSON.parse(fs.readFileSync(path.join(bundledAddonsDir(), de.name, "addon.json"), "utf8")); + if (m && m.id) bundled.add(String(m.id)); + if (m && Array.isArray(m.absorbs)) for (const a of m.absorbs) absorbed.add(String(a)); + } catch {} + } + } catch {} + firstPartyIdsCache = { bundled, absorbed }; + return firstPartyIdsCache; +} // Copy bundled reference add-ons (shipped inside resources/) into the user's // addons directory. Users can then edit, disable, or delete them — the // framework treats bundled and user add-ons identically, no special path @@ -2532,6 +2554,8 @@ function initAddons() { }, approvalModal: (opts, addonId) => showApprovalModal(opts, addonId), vaultRequestUnlock: (opts, addonId) => requestVaultUnlock({ reason: opts && opts.reason, addonId }), + isFirstPartyId: (id) => firstPartyAddonIds().bundled.has(String(id)), + isReservedId: (id) => firstPartyAddonIds().absorbed.has(String(id)), emitToPanel: (addonId, msg, payload) => { // Full-tab pages of the same add-on hear it too. addon-tab-preload has // always exposed silentmode.on(), but nothing ever delivered to a tab, @@ -5427,6 +5451,11 @@ async function installCommunityById(id) { const card = (await fetchCommunityCatalog()).find((e) => e.id === id); if (!card) return { ok: false, error: "not in the catalog" }; if (fs.existsSync(path.join(bundledAddonsDir(), id, "addon.json"))) return { ok: false, error: "id belongs to a built-in add-on" }; + // Also the manifest ids (a folder can be named differently) and the + // legacy ids a built-in add-on absorbs: "bchwallet" is Aegis's key + // namespace even though no folder of that name ships any more. + const fp = firstPartyAddonIds(); + if (fp.bundled.has(id) || fp.absorbed.has(id)) return { ok: false, error: "id is reserved by a built-in add-on" }; const r = await addonUpdater.installCommunity({ id, updatesUrl: card.updatesUrl, publisher: card.publisher, addonsDir: addonsUserDir(), backupsDir: addonsBackupDir(), @@ -5665,6 +5694,14 @@ function routeWizUri(uri, origin, tabId) { ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => { const tab = tabForSender(e.sender); if (!tab || !addonHost) throw new Error("not a page"); + // Only the tab's top-level document speaks for its origin. The inject + // preload runs in the main frame alone, so a message from any other frame + // did not come through it — and it would be credited with the top-level + // URL below. A frame that has since navigated away (senderFrame null) + // gets the same answer. + let mainFrame = null; + try { mainFrame = e.sender.mainFrame; } catch {} + if (!e.senderFrame || (mainFrame && e.senderFrame !== mainFrame)) throw new Error("not the top-level page"); const url = e.sender.getURL(); const id = String(addonId || ""); if (!addonHost.pageAllowed(id, url)) throw new Error(`add-on "${id}" is not injected on this page`);