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:
parent
f27f3d27c9
commit
2dfa9e9c3e
3 changed files with 66 additions and 2 deletions
|
|
@ -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('", "')}"`
|
||||
|
|
|
|||
|
|
@ -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
37
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`);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue