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:
parent
7cc66ce680
commit
da0bad5307
2 changed files with 46 additions and 2 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue