From da0bad53072b9f4732047e941b50b810d433da78 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 5 Oct 2026 23:41:30 +0200 Subject: [PATCH] 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). --- addon-updater.js | 36 +++++++++++++++++++++++++++++++++++- addons-host.js | 12 +++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) 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 d04169bd..e3b09478 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