refactor(security): centralize path guard helpers

This commit is contained in:
Peter Steinberger
2026-02-21 19:54:19 +01:00
parent 6ffca36284
commit ed960ba4eb
5 changed files with 178 additions and 102 deletions

View File

@@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import fs from "node:fs/promises";
import path from "node:path";
import { isNotFoundPathError, isPathInside, isSymlinkOpenError } from "./path-guards.js";
export type SafeOpenErrorCode =
| "invalid-path"
@@ -34,27 +35,17 @@ export type SafeLocalReadResult = {
stat: Stats;
};
const NOT_FOUND_CODES = new Set(["ENOENT", "ENOTDIR"]);
const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
const ensureTrailingSep = (value: string) => (value.endsWith(path.sep) ? value : value + path.sep);
const isNodeError = (err: unknown): err is NodeJS.ErrnoException =>
Boolean(err && typeof err === "object" && "code" in (err as Record<string, unknown>));
const isNotFoundError = (err: unknown) =>
isNodeError(err) && typeof err.code === "string" && NOT_FOUND_CODES.has(err.code);
const isSymlinkOpenError = (err: unknown) =>
isNodeError(err) && (err.code === "ELOOP" || err.code === "EINVAL" || err.code === "ENOTSUP");
async function openVerifiedLocalFile(filePath: string): Promise<SafeOpenResult> {
let handle: FileHandle;
try {
handle = await fs.open(filePath, OPEN_READ_FLAGS);
} catch (err) {
if (isNotFoundError(err)) {
if (isNotFoundPathError(err)) {
throw new SafeOpenError("not-found", "file not found");
}
if (isSymlinkOpenError(err)) {
@@ -87,7 +78,7 @@ async function openVerifiedLocalFile(filePath: string): Promise<SafeOpenResult>
if (err instanceof SafeOpenError) {
throw err;
}
if (isNotFoundError(err)) {
if (isNotFoundPathError(err)) {
throw new SafeOpenError("not-found", "file not found");
}
throw err;
@@ -102,14 +93,14 @@ export async function openFileWithinRoot(params: {
try {
rootReal = await fs.realpath(params.rootDir);
} catch (err) {
if (isNotFoundError(err)) {
if (isNotFoundPathError(err)) {
throw new SafeOpenError("not-found", "root dir not found");
}
throw err;
}
const rootWithSep = ensureTrailingSep(rootReal);
const resolved = path.resolve(rootWithSep, params.relativePath);
if (!resolved.startsWith(rootWithSep)) {
if (!isPathInside(rootWithSep, resolved)) {
throw new SafeOpenError("invalid-path", "path escapes root");
}
@@ -128,7 +119,7 @@ export async function openFileWithinRoot(params: {
throw err;
}
if (!opened.realPath.startsWith(rootWithSep)) {
if (!isPathInside(rootWithSep, opened.realPath)) {
await opened.handle.close().catch(() => {});
throw new SafeOpenError("invalid-path", "path escapes root");
}