Merge branch 'claude/goofy-dubinsky-1f8f33' into release/0.3.83

This commit is contained in:
Local Dev 2026-10-06 00:00:12 +02:00
commit 200b64f4c8
2 changed files with 46 additions and 2 deletions

View file

@ -249,6 +249,19 @@ async function fetchVerifiedPackage({ id, version, url, sha256, log }) {
return { status: "extract-failed", newVer: version, detail: e.message };
}
// No links of any kind. A community extension runs sandboxed with read
// access to its own folder, and Node's permission model follows links, so
// a packaged symlink or junction to the profile (passwords.vault, the
// wallet store) would be a way out. tar.exe only fails to create symlinks
// where Windows withholds the privilege; with Developer Mode it makes them.
const links = findLinks(tmpDir);
if (links.length) {
log(`updates: ${id}@${version} contains links (${links.slice(0, 3).join(", ")}), refusing`);
try { fs.rmSync(tmpFile, { force: true }); } catch {}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
return { status: "extract-failed", newVer: version, detail: "package contains links" };
}
// A package may wrap everything in one top-level folder (tar -czf x.tgz my-ext);
// unwrap it so addon.json sits at the root like the bundled add-ons.
let root = tmpDir;
@ -266,6 +279,27 @@ async function fetchVerifiedPackage({ id, version, url, sha256, log }) {
return { ok: true, tmpDir: root, tmpTop: tmpDir, tmpFile, manifest: extracted };
}
// Every symlink, junction or hard-linked file under root (relative paths).
// lstat reports a junction as a symbolic link; it is not followed.
function findLinks(root) {
const out = [];
const walk = (dir, rel) => {
let entries = [];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const de of entries) {
const p = path.join(dir, de.name), r = rel ? rel + "/" + de.name : de.name;
let st;
try { st = fs.lstatSync(p); } catch { out.push(r); continue; }
if (st.isSymbolicLink()) { out.push(r); continue; }
if (st.isDirectory()) { walk(p, r); continue; }
if (st.isFile() && st.nlink > 1) out.push(r);
else if (!st.isFile()) out.push(r);
}
};
walk(root, "");
return out;
}
// Move an extracted package into place (rename, with copy fallback across volumes).
function placeDir(from, to) {
try { fs.renameSync(from, to); }
@ -420,7 +454,7 @@ async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, verifyPu
return { report };
}
module.exports = { communityUpdateProblem,
module.exports = { communityUpdateProblem, findLinks,
cmpVer,
promoteStagedUpdates,
checkAndStageUpdates,

View file

@ -635,7 +635,17 @@ class AddonHost {
let mod;
const holder = { active: null };
const sandboxed = this._shouldSandbox(manifest);
if (sandboxed) mod = this._sandboxModule(manifest, folder, mainPath, holder);
if (sandboxed) {
// The permission model follows links, so read access to a folder that
// holds a symlink, junction or hard link to the profile is read access
// to the profile. Installs refuse such packages; a folder that has one
// anyway (an older install, a hand edit) does not start.
const { findLinks } = require("./addon-updater.js");
const scratch = path.join(this.dataDir, ".sandbox", "scratch", manifest.id);
const links = [...findLinks(folder), ...(fs.existsSync(scratch) ? findLinks(scratch).map((r) => "scratch/" + r) : [])];
if (links.length) throw new Error(`not started: the extension folder contains links (${links.slice(0, 3).join(", ")})`);
mod = this._sandboxModule(manifest, folder, mainPath, holder);
}
else try {
// Bust the require cache so a manual reload picks up edits — cheap since
// add-ons are small. When the version changed (a hot-applied update) the