Initial commit
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user