diff --git a/addon-updater.js b/addon-updater.js index cbab3b68..8c7d2864 100644 --- a/addon-updater.js +++ b/addon-updater.js @@ -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, diff --git a/addons-host.js b/addons-host.js index dc66af6e..76c471a3 100644 --- a/addons-host.js +++ b/addons-host.js @@ -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