Files
2026-05-26 22:46:00 +02:00

59 lines
2.4 KiB
TypeScript

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<string | null> {
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;
}