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

@@ -10,6 +10,7 @@ import {
stripArchivePath,
validateArchiveEntryPath,
} from "./archive-path.js";
import { isNotFoundPathError, isPathInside, isSymlinkOpenError } from "./path-guards.js";
export type ArchiveKind = "tar" | "zip";
@@ -32,6 +33,21 @@ export type ArchiveExtractLimits = {
maxEntryBytes?: number;
};
export type ArchiveSecurityErrorCode =
| "destination-not-directory"
| "destination-symlink"
| "destination-symlink-traversal";
export class ArchiveSecurityError extends Error {
code: ArchiveSecurityErrorCode;
constructor(code: ArchiveSecurityErrorCode, message: string, options?: ErrorOptions) {
super(message, options);
this.code = code;
this.name = "ArchiveSecurityError";
}
}
/** @internal */
export const DEFAULT_MAX_ARCHIVE_BYTES_ZIP = 256 * 1024 * 1024;
/** @internal */
@@ -196,43 +212,27 @@ function createExtractBudgetTransform(params: {
});
}
function isNodeError(value: unknown): value is NodeJS.ErrnoException {
return Boolean(
value && typeof value === "object" && "code" in (value as Record<string, unknown>),
function symlinkTraversalError(originalPath: string): ArchiveSecurityError {
return new ArchiveSecurityError(
"destination-symlink-traversal",
`${ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK}: ${originalPath}`,
);
}
function isNotFoundError(value: unknown): boolean {
return isNodeError(value) && (value.code === "ENOENT" || value.code === "ENOTDIR");
}
function isSymlinkOpenError(value: unknown): boolean {
return (
isNodeError(value) &&
(value.code === "ELOOP" || value.code === "EINVAL" || value.code === "ENOTSUP")
);
}
function symlinkTraversalError(originalPath: string): Error {
return new Error(`${ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK}: ${originalPath}`);
}
async function assertDestinationDirReady(destDir: string): Promise<string> {
const stat = await fs.lstat(destDir);
if (stat.isSymbolicLink()) {
throw new Error("archive destination is a symlink");
throw new ArchiveSecurityError("destination-symlink", "archive destination is a symlink");
}
if (!stat.isDirectory()) {
throw new Error("archive destination is not a directory");
throw new ArchiveSecurityError(
"destination-not-directory",
"archive destination is not a directory",
);
}
return await fs.realpath(destDir);
}
function pathInside(root: string, target: string): boolean {
const rel = path.relative(root, target);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}
async function assertNoSymlinkTraversal(params: {
rootDir: string;
relPath: string;
@@ -246,7 +246,7 @@ async function assertNoSymlinkTraversal(params: {
try {
stat = await fs.lstat(current);
} catch (err) {
if (isNotFoundError(err)) {
if (isNotFoundPathError(err)) {
continue;
}
throw err;
@@ -266,12 +266,12 @@ async function assertResolvedInsideDestination(params: {
try {
resolved = await fs.realpath(params.targetPath);
} catch (err) {
if (isNotFoundError(err)) {
if (isNotFoundPathError(err)) {
return;
}
throw err;
}
if (!pathInside(params.destinationRealDir, resolved)) {
if (!isPathInside(params.destinationRealDir, resolved)) {
throw symlinkTraversalError(params.originalPath);
}
}
@@ -292,7 +292,7 @@ async function cleanupPartialRegularFile(filePath: string): Promise<void> {
try {
stat = await fs.lstat(filePath);
} catch (err) {
if (isNotFoundError(err)) {
if (isNotFoundPathError(err)) {
return;
}
throw err;
@@ -310,6 +310,8 @@ type ZipEntry = {
async: (type: "nodebuffer") => Promise<Buffer>;
};
type ZipExtractBudget = ReturnType<typeof createByteBudgetTracker>;
async function readZipEntryStream(entry: ZipEntry): Promise<NodeJS.ReadableStream> {
if (typeof entry.nodeStream === "function") {
return entry.nodeStream();
@@ -319,6 +321,90 @@ async function readZipEntryStream(entry: ZipEntry): Promise<NodeJS.ReadableStrea
return Readable.from(buf);
}
function resolveZipOutputPath(params: {
entryPath: string;
strip: number;
destinationDir: string;
}): { relPath: string; outPath: string } | null {
validateArchiveEntryPath(params.entryPath);
const relPath = stripArchivePath(params.entryPath, params.strip);
if (!relPath) {
return null;
}
validateArchiveEntryPath(relPath);
return {
relPath,
outPath: resolveArchiveOutputPath({
rootDir: params.destinationDir,
relPath,
originalPath: params.entryPath,
}),
};
}
async function prepareZipOutputPath(params: {
destinationDir: string;
destinationRealDir: string;
relPath: string;
outPath: string;
originalPath: string;
isDirectory: boolean;
}): Promise<void> {
await assertNoSymlinkTraversal({
rootDir: params.destinationDir,
relPath: params.relPath,
originalPath: params.originalPath,
});
if (params.isDirectory) {
await fs.mkdir(params.outPath, { recursive: true });
await assertResolvedInsideDestination({
destinationRealDir: params.destinationRealDir,
targetPath: params.outPath,
originalPath: params.originalPath,
});
return;
}
const parentDir = path.dirname(params.outPath);
await fs.mkdir(parentDir, { recursive: true });
await assertResolvedInsideDestination({
destinationRealDir: params.destinationRealDir,
targetPath: parentDir,
originalPath: params.originalPath,
});
}
async function writeZipFileEntry(params: {
entry: ZipEntry;
outPath: string;
budget: ZipExtractBudget;
}): Promise<void> {
const handle = await openZipOutputFile(params.outPath, params.entry.name);
params.budget.startEntry();
const readable = await readZipEntryStream(params.entry);
const writable = handle.createWriteStream();
try {
await pipeline(
readable,
createExtractBudgetTransform({ onChunkBytes: params.budget.addBytes }),
writable,
);
} catch (err) {
await cleanupPartialRegularFile(params.outPath).catch(() => undefined);
throw err;
}
// Best-effort permission restore for zip entries created on unix.
if (typeof params.entry.unixPermissions === "number") {
const mode = params.entry.unixPermissions & 0o777;
if (mode !== 0) {
await fs.chmod(params.outPath, mode).catch(() => undefined);
}
}
}
async function extractZip(params: {
archivePath: string;
destDir: string;
@@ -342,63 +428,32 @@ async function extractZip(params: {
const budget = createByteBudgetTracker(limits);
for (const entry of entries) {
validateArchiveEntryPath(entry.name);
const relPath = stripArchivePath(entry.name, strip);
if (!relPath) {
const output = resolveZipOutputPath({
entryPath: entry.name,
strip,
destinationDir: params.destDir,
});
if (!output) {
continue;
}
validateArchiveEntryPath(relPath);
const outPath = resolveArchiveOutputPath({
rootDir: params.destDir,
relPath,
originalPath: entry.name,
});
await assertNoSymlinkTraversal({
rootDir: params.destDir,
relPath,
await prepareZipOutputPath({
destinationDir: params.destDir,
destinationRealDir,
relPath: output.relPath,
outPath: output.outPath,
originalPath: entry.name,
isDirectory: entry.dir,
});
if (entry.dir) {
await fs.mkdir(outPath, { recursive: true });
await assertResolvedInsideDestination({
destinationRealDir,
targetPath: outPath,
originalPath: entry.name,
});
continue;
}
await fs.mkdir(path.dirname(outPath), { recursive: true });
await assertResolvedInsideDestination({
destinationRealDir,
targetPath: path.dirname(outPath),
originalPath: entry.name,
await writeZipFileEntry({
entry,
outPath: output.outPath,
budget,
});
const handle = await openZipOutputFile(outPath, entry.name);
budget.startEntry();
const readable = await readZipEntryStream(entry);
const writable = handle.createWriteStream();
try {
await pipeline(
readable,
createExtractBudgetTransform({ onChunkBytes: budget.addBytes }),
writable,
);
} catch (err) {
await cleanupPartialRegularFile(outPath).catch(() => undefined);
throw err;
}
// Best-effort permission restore for zip entries created on unix.
if (typeof entry.unixPermissions === "number") {
const mode = entry.unixPermissions & 0o777;
if (mode !== 0) {
await fs.chmod(outPath, mode).catch(() => undefined);
}
}
}
}