import fs from "node:fs"; import path from "node:path"; import { VaultEscapeError } from "../errors.js"; /** * Validate that `relPath` resolves to a location inside `vaultRoot`, and * return the resolved absolute path. Throws VaultEscapeError otherwise. * * Defends against: * - absolute paths passed as "relative" * - `..` segments that walk out of the vault (even if they net back inside, * we reject on principle — see below for the "stays inside" test case, * which is allowed because the normalized path never leaves the root) * - symlinks inside the vault that point outside of it */ export function assertInsideVault(vaultRoot: string, relPath: string): string { if (path.isAbsolute(relPath)) { throw new VaultEscapeError(`Path must be relative: ${relPath}`); } const normalized = path.normalize(relPath); if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) { throw new VaultEscapeError(`Path escapes vault: ${relPath}`); } const resolvedRoot = path.resolve(vaultRoot); const candidate = path.resolve(resolvedRoot, normalized); // Belt-and-suspenders: candidate must still be within the root textually. if (candidate !== resolvedRoot && !candidate.startsWith(resolvedRoot + path.sep)) { throw new VaultEscapeError(`Path escapes vault: ${relPath}`); } // Symlink defense: realpath the deepest *existing* ancestor of the // candidate (the candidate itself may not exist yet, e.g. a new file // about to be written) and verify it lives inside the realpath'd vault // root. This catches a symlink anywhere along the existing path prefix // that would otherwise redirect us outside the vault. let realRoot: string; try { realRoot = fs.realpathSync(resolvedRoot); } catch { // Vault root doesn't exist on disk (shouldn't normally happen) — fall // back to the textual root. realRoot = resolvedRoot; } // Walk up to the deepest ancestor that exists *as a filesystem entry of any // kind*. Use lstatSync, not existsSync: existsSync follows symlinks, so a // dangling symlink (e.g. vault/link -> /outside/does-not-exist) would be // treated as "not existing" and skipped, letting a later mkdir -p + write // follow the link and escape the vault. lstatSync treats the symlink itself // as an existing entry so we stop on it and resolve its real target below. let ancestor = candidate; while (true) { try { fs.lstatSync(ancestor); break; } catch { const parent = path.dirname(ancestor); if (parent === ancestor) break; // reached filesystem root without finding anything ancestor = parent; } } // Fully resolve the ancestor (following any symlinks). If it's a dangling // symlink, realpathSync throws — we reject, because we cannot prove the link // stays inside the vault and following it on write could escape. let realAncestor: string; try { realAncestor = fs.realpathSync(ancestor); } catch { throw new VaultEscapeError(`Path resolves through an unresolvable/broken symlink: ${relPath}`); } if (realAncestor !== realRoot && !realAncestor.startsWith(realRoot + path.sep)) { throw new VaultEscapeError(`Path escapes vault via symlink: ${relPath}`); } return candidate; }