Theseus: an add-on's key namespace cannot be taken by another add-on

- `absorbs` lets one add-on derive under another's vault namespace. A
  community install has the field stripped, but an update of one is placed
  as shipped, so a second version could declare absorbs:["aegis"] and derive
  the wallet's keys. It is now honoured only for ids that ship inside
  Theseus, at the one place that matters: vault.derive.
- The legacy ids Aegis absorbs ("bchwallet", "siawallet") no longer have a
  bundled folder, so nothing stopped a catalog extension from installing
  under one and deriving `bchwallet/...`. They are reserved at install and
  refused at derive.
- A page-to-add-on message is accepted only from the tab's top-level frame.
  The origin shown to the user is the top-level URL, so a subframe that
  reached the channel would have been credited with its parent's origin.
- The approval overlay ignores everything but Cancel for the first 800 ms.
  A page can raise it without a gesture and knows where the primary button
  lands, which made "double-click here" a way to approve a spend.
This commit is contained in:
Local Dev 2026-10-04 01:41:01 +02:00
parent f27f3d27c9
commit 2dfa9e9c3e
3 changed files with 66 additions and 2 deletions

View file

@ -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}/<name>"`);
}
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('", "')}"`

View file

@ -82,8 +82,20 @@
</div></div>`;
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.

37
main.js
View file

@ -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`);