59 lines
2.4 KiB
TypeScript
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;
|
|
}
|