From 21964ce3850f03560eac2d5198a98a44cf96fa95 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 04:25:23 +0200 Subject: [PATCH] Theseus: community updates cannot widen what an extension may do A community install strips category and absorbs and asks the user, but an update of the same extension was staged and promoted verbatim, so version 2 could claim first-party placement or quietly add capabilities and page-inject origins. Publisher-signed updates now get the same manifest rewrite, and one that asks for new capabilities or new pages is not staged; the user approves it by reinstalling from theseus.x. --- addon-updater.js | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/addon-updater.js b/addon-updater.js index 9f168db6..cbab3b68 100644 --- a/addon-updater.js +++ b/addon-updater.js @@ -272,7 +272,24 @@ function placeDir(from, to) { catch { fs.cpSync(from, to, { recursive: true }); fs.rmSync(from, { recursive: true, force: true }); } } -async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, verifyPublisher, publisher, log, timeoutMs }) { +// A community (publisher-signed) update is placed under the same rules as a +// community install: it cannot claim first-party placement (category) or +// another add-on's key namespace (absorbs), and it cannot widen what it may +// do — new capabilities or new page-inject origins — without the user's +// consent, which only a reinstall from theseus.x asks for. Such an update is +// not staged; the installed version keeps running. +function communityUpdateProblem(oldMf, newMf) { + const oldCaps = new Set(Array.isArray(oldMf?.capabilities) ? oldMf.capabilities : []); + const added = (Array.isArray(newMf?.capabilities) ? newMf.capabilities : []).filter((c) => !oldCaps.has(c)); + if (added.length) return `asks for new capabilities (${added.join(", ")})`; + const origins = (m) => JSON.stringify(((m && m["page-inject"] && m["page-inject"].origins) || []).slice().sort()); + const oldPre = oldMf && oldMf["page-inject"] && oldMf["page-inject"].preload; + const newPre = newMf && newMf["page-inject"] && newMf["page-inject"].preload; + if ((newPre && !oldPre) || origins(oldMf) !== origins(newMf) && newPre) return "changes which pages it is injected into"; + return null; +} + +async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, verifyPublisher, publisher, currentManifest, log, timeoutMs }) { const picked = await pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, publisher, log, timeoutMs }); if (picked.status !== "ok") return picked; const best = picked.best; @@ -284,6 +301,22 @@ async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, veri const pkg = await fetchVerifiedPackage({ id, version: best.version, url: best.url, sha256: best.sha256, log }); if (!pkg.ok) return pkg; + if (publisher) { + const problem = communityUpdateProblem(currentManifest, pkg.manifest); + if (problem) { + log(`updates: ${id}@${best.version} not staged — it ${problem}; reinstall it from theseus.x to approve`); + try { fs.rmSync(pkg.tmpTop, { recursive: true, force: true }); } catch {} + try { fs.rmSync(pkg.tmpFile, { force: true }); } catch {} + return { status: "needs-consent", newVer: best.version, detail: problem }; + } + try { + const mf = { ...pkg.manifest }; + delete mf.category; delete mf.absorbs; + mf.publisher = best.publisher || publisher; + if (!mf.updateURL) mf.updateURL = updateURL; + fs.writeFileSync(path.join(pkg.tmpDir, "addon.json"), JSON.stringify(mf, null, 2)); + } catch (e) { log(`updates: ${id} manifest rewrite failed:`, e?.message); return { status: "extract-failed", newVer: best.version, detail: "manifest rewrite" }; } + } try { fs.mkdirSync(stagedDir, { recursive: true }); } catch {} try { placeDir(pkg.tmpDir, stageOut); } catch (ee) { log(`updates: stage move ${id}@${best.version} failed:`, ee.message); return { status: "extract-failed", newVer: best.version, detail: "stage move: " + ee.message }; } @@ -376,6 +409,7 @@ async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, verifyPu currentVer: manifest.version, updateURL: manifest.updateURL, publisher: manifest.publisher || null, + currentManifest: manifest, stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs, }) .then((r) => report.push({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, ...r })) @@ -386,7 +420,7 @@ async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, verifyPu return { report }; } -module.exports = { +module.exports = { communityUpdateProblem, cmpVer, promoteStagedUpdates, checkAndStageUpdates,