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.
This commit is contained in:
Local Dev 2026-10-04 04:25:23 +02:00
parent bc1b5fc0f3
commit 21964ce385

View file

@ -272,7 +272,24 @@ function placeDir(from, to) {
catch { fs.cpSync(from, to, { recursive: true }); fs.rmSync(from, { recursive: true, force: true }); } 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 }); const picked = await pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, publisher, log, timeoutMs });
if (picked.status !== "ok") return picked; if (picked.status !== "ok") return picked;
const best = picked.best; 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 }); const pkg = await fetchVerifiedPackage({ id, version: best.version, url: best.url, sha256: best.sha256, log });
if (!pkg.ok) return pkg; 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 { fs.mkdirSync(stagedDir, { recursive: true }); } catch {}
try { placeDir(pkg.tmpDir, stageOut); } 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 }; } 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, currentVer: manifest.version,
updateURL: manifest.updateURL, updateURL: manifest.updateURL,
publisher: manifest.publisher || null, publisher: manifest.publisher || null,
currentManifest: manifest,
stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs, stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs,
}) })
.then((r) => report.push({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, ...r })) .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 }; return { report };
} }
module.exports = { module.exports = { communityUpdateProblem,
cmpVer, cmpVer,
promoteStagedUpdates, promoteStagedUpdates,
checkAndStageUpdates, checkAndStageUpdates,