import { realpath } from "node:fs/promises"; import { isAbsolute, resolve, sep } from "node:path"; import { WORK_ROOT } from "./config"; import { invalid } from "./errors"; /** * Turning a caller-supplied path into one this service will touch. * * ── This is a deliberate change to the invariant in docs/SECURITY.md ── * * That document said the caller never supplies a path, for input or output, and everything else was * built on it. It was the right invariant for an upload service and the wrong one for what av-club is * actually used by: a right-click on a file that is already on disk, and a commander watching a folder * of them. Both had the file already; both would have had to copy gigabytes in and gigabytes back out * to obey a rule whose purpose was to stop ffmpeg reading things it should not. * * So the rule is now narrower and says what it was always for: **the caller may name a path, and this * function is the only thing that decides whether it is one we will touch.** Every other layer is * unchanged — argv never a shell, `-protocol_whitelist file`, filters allowlisted by name, work * bounded. What is gone is only the claim that a path never appears. * * ── What it checks, and why each one ── * * ABSOLUTE. A relative path resolves against this process's cwd, which is not the caller's and is not * anything they can see. There is no useful meaning for it, so it is refused rather than guessed at. * * REAL. `realpath` follows every symlink before the check, so a link inside the root pointing at * /etc/shadow is resolved and then rejected. Checking the path as written and opening the resolved one * is the classic way this goes wrong. * * INSIDE THE ROOT, tested as `root` or `root + sep`. A bare `startsWith` accepts `/home/user-backup` * for a root of `/home/user`, which is a different person's files — the same bug the platform's own * file browser documents in `isInside`. */ /** A path this service is willing to read from or write to. Branded so it cannot be confused with a string. */ export type SafePath = string & { readonly __safe: unique symbol }; const inside = (root: string, target: string) => target === root || target.startsWith(root + sep); /** * For an INPUT: the file must exist, so its real path can be resolved and checked. * * Errors deliberately do not distinguish "outside the root" from "does not exist". Telling a caller * which it was turns this into an oracle for what exists on the filesystem outside their root. */ export async function safeInputPath(raw: unknown, field: string): Promise { if (typeof raw !== "string" || !raw.trim()) throw invalid(`${field} must be a path`, field); if (!isAbsolute(raw)) throw invalid(`${field} must be an absolute path`, field); const real = await realpath(resolve(raw)).catch(() => null); if (!real || !inside(WORK_ROOT, real)) throw invalid(`${field} is not a readable path`, field); return real as SafePath; } /** * For an OUTPUT: the file does not exist yet, so its PARENT is what gets resolved and checked. * * The parent must already exist. Creating directories on the way to an output is a side effect nobody * asked for, and a typo would silently scatter empty folders through somebody's home. */ export async function safeOutputPath(raw: unknown, field: string): Promise { if (typeof raw !== "string" || !raw.trim()) throw invalid(`${field} must be a path`, field); if (!isAbsolute(raw)) throw invalid(`${field} must be an absolute path`, field); const full = resolve(raw); const name = full.slice(full.lastIndexOf(sep) + 1); if (!name || name === "." || name === "..") throw invalid(`${field} must name a file`, field); const parent = await realpath(full.slice(0, full.lastIndexOf(sep)) || sep).catch(() => null); if (!parent || !inside(WORK_ROOT, parent)) throw invalid(`${field} is not a writable path`, field); return `${parent}${sep}${name}` as SafePath; }