import "server-only"; import path from "node:path"; import fs from "node:fs/promises"; /** * Resolve `relPath` against `root` and confirm the result stays inside * `root`. Returns null on any escape attempt or invalid input. * * Defense layers: * - Reject empty/null/non-string input. * - Reject NUL bytes (POSIX truncation tricks). * - Reject absolute paths (drive letters, leading slash). path.resolve * silently honors absolute second args, which would let a caller * escape root before path.relative even runs. * - Reject Windows-style separators on POSIX so `..\foo` isn't smuggled * past the relative-check. * - Final containment check via path.relative — escape paths surface * as `..` or remain absolute. * * Symlinks are NOT resolved (fs is async, callers are sync). Use * safeRealJoin for paths that may contain user-controlled symlinks. */ export function safeJoin(root: string, relPath: string | null | undefined): string | null { if (!relPath || typeof relPath !== "string") return null; if (relPath.includes("\0")) return null; if (path.isAbsolute(relPath)) return null; // path.win32.isAbsolute treats `C:` and `\\server\share` as absolute; // on POSIX path.isAbsolute won't catch those, so check explicitly. if (process.platform !== "win32" && /^[a-zA-Z]:[\\/]|^\\\\/.test(relPath)) return null; const abs = path.resolve(root, relPath); const rel = path.relative(root, abs); if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null; return abs; } /** * Async variant: like safeJoin, but additionally resolves symlinks via * realpath and re-verifies the resolved target stays inside root. * Use when the path may live under a directory the user can write * symlinks into (e.g. external library/subtitle roots). */ export async function safeRealJoin(root: string, relPath: string | null | undefined): Promise { const abs = safeJoin(root, relPath); if (!abs) return null; let real: string; try { real = await fs.realpath(abs); } catch { // File may not exist yet (e.g. about-to-be-written target). Fall // back to the static check — caller is responsible for not creating // symlinks at this path. return abs; } const rootReal = await fs.realpath(root).catch(() => path.resolve(root)); const rel = path.relative(rootReal, real); if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null; return real; }