Theseus: no links in a sandboxed extension's folder

A community extension runs under Node's permission model with read
access to its own folder, and the permission model follows symlinks
and junctions. A signed package carrying a link into the profile
(passwords.vault, the wallet store) would therefore be read access to
the profile. Windows' tar.exe fails to create symlinks only where the
privilege is withheld; with Developer Mode it creates them. Extracted
packages containing any symlink, junction, hard link or special file
are now refused, and an extension whose installed folder or scratch
folder contains one is not started (the sandbox itself cannot create
links: Node refuses symlink creation without full fs permission).
This commit is contained in:
Local Dev 2026-10-05 23:41:30 +02:00
parent 7cc66ce680
commit da0bad5307
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 }; 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); // 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. // unwrap it so addon.json sits at the root like the bundled add-ons.
let root = tmpDir; 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 }; 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). // Move an extracted package into place (rename, with copy fallback across volumes).
function placeDir(from, to) { function placeDir(from, to) {
try { fs.renameSync(from, to); } try { fs.renameSync(from, to); }
@ -420,7 +454,7 @@ async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, verifyPu
return { report }; return { report };
} }
module.exports = { communityUpdateProblem, module.exports = { communityUpdateProblem, findLinks,
cmpVer, cmpVer,
promoteStagedUpdates, promoteStagedUpdates,
checkAndStageUpdates, checkAndStageUpdates,

View file

@ -635,7 +635,17 @@ class AddonHost {
let mod; let mod;
const holder = { active: null }; const holder = { active: null };
const sandboxed = this._shouldSandbox(manifest); 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 { else try {
// Bust the require cache so a manual reload picks up edits — cheap since // 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 // add-ons are small. When the version changed (a hot-applied update) the