refactor(security): split gateway auth suites and share safe write path checks

This commit is contained in:
Peter Steinberger
2026-03-02 18:06:58 +00:00
parent 602f6439bd
commit 83c8406f01
9 changed files with 1050 additions and 994 deletions

View File

@@ -1,4 +1,3 @@
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";
@@ -11,9 +10,8 @@ import {
stripArchivePath,
validateArchiveEntryPath,
} from "./archive-path.js";
import { sameFileIdentity } from "./file-identity.js";
import { resolveOpenedFileRealPathForHandle } from "./fs-safe.js";
import { isNotFoundPathError, isPathInside, isSymlinkOpenError } from "./path-guards.js";
import { openWritableFileWithinRoot, SafeOpenError } from "./fs-safe.js";
import { isNotFoundPathError, isPathInside } from "./path-guards.js";
export type ArchiveKind = "tar" | "zip";
@@ -67,14 +65,6 @@ const ERROR_ARCHIVE_EXTRACTED_SIZE_EXCEEDS_LIMIT = "archive extracted size excee
const ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK = "archive entry traverses symlink in destination";
const TAR_SUFFIXES = [".tgz", ".tar.gz", ".tar"];
const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
const OPEN_WRITE_EXISTING_FLAGS =
fsConstants.O_WRONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
const OPEN_WRITE_CREATE_FLAGS =
fsConstants.O_WRONLY |
fsConstants.O_CREAT |
fsConstants.O_EXCL |
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
export function resolveArchiveKind(filePath: string): ArchiveKind | null {
const lower = filePath.toLowerCase();
@@ -288,93 +278,28 @@ type OpenZipOutputFileResult = {
};
async function openZipOutputFile(params: {
outPath: string;
relPath: string;
originalPath: string;
destinationRealDir: string;
}): Promise<OpenZipOutputFileResult> {
let ioPath = params.outPath;
try {
const resolvedRealPath = await fs.realpath(params.outPath);
if (!isPathInside(params.destinationRealDir, resolvedRealPath)) {
throw symlinkTraversalError(params.originalPath);
}
ioPath = resolvedRealPath;
return await openWritableFileWithinRoot({
rootDir: params.destinationRealDir,
relativePath: params.relPath,
mkdir: false,
mode: 0o666,
});
} catch (err) {
if (err instanceof ArchiveSecurityError) {
throw err;
}
if (!isNotFoundPathError(err)) {
throw err;
}
}
let handle: FileHandle;
let createdForWrite = false;
try {
try {
handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, 0o666);
} catch (err) {
if (!isNotFoundPathError(err)) {
throw err;
}
handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, 0o666);
createdForWrite = true;
}
} catch (err) {
if (isSymlinkOpenError(err)) {
if (
err instanceof SafeOpenError &&
(err.code === "invalid-path" ||
err.code === "outside-workspace" ||
err.code === "path-mismatch")
) {
throw symlinkTraversalError(params.originalPath);
}
throw err;
}
let openedRealPath: string | null = null;
try {
const stat = await handle.stat();
if (!stat.isFile()) {
throw symlinkTraversalError(params.originalPath);
}
try {
const lstat = await fs.lstat(ioPath);
if (lstat.isSymbolicLink() || !lstat.isFile()) {
throw symlinkTraversalError(params.originalPath);
}
if (!sameFileIdentity(stat, lstat)) {
throw symlinkTraversalError(params.originalPath);
}
} catch (err) {
if (!isNotFoundPathError(err)) {
throw err;
}
}
const realPath = await resolveOpenedFileRealPathForHandle(handle, ioPath);
openedRealPath = realPath;
const realStat = await fs.stat(realPath);
if (!sameFileIdentity(stat, realStat)) {
throw symlinkTraversalError(params.originalPath);
}
if (!isPathInside(params.destinationRealDir, realPath)) {
throw symlinkTraversalError(params.originalPath);
}
// Truncate only after identity + boundary checks complete.
if (!createdForWrite) {
await handle.truncate(0);
}
return {
handle,
createdForWrite,
openedRealPath: realPath,
};
} catch (err) {
if (createdForWrite && openedRealPath) {
await fs.rm(openedRealPath, { force: true }).catch(() => undefined);
}
await handle.close().catch(() => undefined);
throw err;
}
}
async function cleanupPartialRegularFile(filePath: string): Promise<void> {
@@ -467,12 +392,12 @@ async function prepareZipOutputPath(params: {
async function writeZipFileEntry(params: {
entry: ZipEntry;
outPath: string;
relPath: string;
destinationRealDir: string;
budget: ZipExtractBudget;
}): Promise<void> {
const opened = await openZipOutputFile({
outPath: params.outPath,
relPath: params.relPath,
originalPath: params.entry.name,
destinationRealDir: params.destinationRealDir,
});
@@ -558,7 +483,7 @@ async function extractZip(params: {
await writeZipFileEntry({
entry,
outPath: output.outPath,
relPath: output.relPath,
destinationRealDir,
budget,
});

View File

@@ -321,6 +321,7 @@ export async function openWritableFileWithinRoot(params: {
rootDir: string;
relativePath: string;
mkdir?: boolean;
mode?: number;
}): Promise<SafeWritableOpenResult> {
const { rootReal, rootWithSep, resolved } = await resolvePathWithinRoot(params);
try {
@@ -352,16 +353,18 @@ export async function openWritableFileWithinRoot(params: {
}
}
const fileMode = params.mode ?? 0o600;
let handle: FileHandle;
let createdForWrite = false;
try {
try {
handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, 0o600);
handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, fileMode);
} catch (err) {
if (!isNotFoundPathError(err)) {
throw err;
}
handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, 0o600);
handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, fileMode);
createdForWrite = true;
}
} catch (err) {