fix(archive): pin staged merge mutations

This commit is contained in:
Peter Steinberger 2026-05-06 20:57:24 +01:00
parent ee0eb18a6d
commit 4658071a89
No known key found for this signature in database

View File

@ -1,9 +1,11 @@
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import { FsSafeError } from "./errors.js";
import { root } from "./root.js";
import { resolveOpenedFileRealPathForHandle, root } from "./root.js";
import { isNotFoundPathError, isPathInside } from "./path.js";
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
import { getFsSafeTestHooks } from "./test-hooks.js";
const ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK = "archive entry traverses symlink in destination";
const ARCHIVE_STAGING_MODE = 0o700;
@ -30,6 +32,37 @@ function symlinkTraversalError(originalPath: string): ArchiveSecurityError {
);
}
type DirectoryIdentityGuard = {
dir: string;
realPath: string;
dev: number | bigint;
ino: number | bigint;
};
async function createDirectoryIdentityGuard(dir: string): Promise<DirectoryIdentityGuard> {
const stat = await fs.lstat(dir);
if (stat.isSymbolicLink() || !stat.isDirectory()) {
throw new ArchiveSecurityError("destination-symlink", "archive destination is a symlink");
}
return { dir, realPath: await fs.realpath(dir), dev: stat.dev, ino: stat.ino };
}
async function assertDirectoryIdentityGuard(guard: DirectoryIdentityGuard): Promise<void> {
const stat = await fs.lstat(guard.dir);
if (
stat.isSymbolicLink() ||
!stat.isDirectory() ||
stat.dev !== guard.dev ||
stat.ino !== guard.ino ||
(await fs.realpath(guard.dir)) !== guard.realPath
) {
throw new ArchiveSecurityError(
"destination-symlink-traversal",
"archive destination changed during extraction",
);
}
}
export async function prepareArchiveDestinationDir(destDir: string): Promise<string> {
const stat = await fs.lstat(destDir);
if (stat.isSymbolicLink()) {
@ -95,14 +128,20 @@ export async function prepareArchiveOutputPath(params: {
originalPath: string;
isDirectory: boolean;
}): Promise<void> {
const targetRoot = await root(params.destinationRealDir);
const destinationGuard = await createDirectoryIdentityGuard(params.destinationRealDir);
const relPath = params.relPath.split(path.sep).join(path.posix.sep);
await assertNoSymlinkTraversal({
rootDir: params.destinationDir,
relPath: params.relPath,
relPath,
originalPath: params.originalPath,
});
if (params.isDirectory) {
await fs.mkdir(params.outPath, { recursive: true });
await getFsSafeTestHooks()?.beforeArchiveOutputMutation?.("mkdir", params.outPath);
await assertDirectoryIdentityGuard(destinationGuard);
await targetRoot.mkdir(relPath);
await assertDirectoryIdentityGuard(destinationGuard);
await assertResolvedInsideDestination({
destinationRealDir: params.destinationRealDir,
targetPath: params.outPath,
@ -111,15 +150,59 @@ export async function prepareArchiveOutputPath(params: {
return;
}
const parentDir = path.dirname(params.outPath);
await fs.mkdir(parentDir, { recursive: true });
const parentRel = path.posix.dirname(relPath);
if (parentRel !== ".") {
await getFsSafeTestHooks()?.beforeArchiveOutputMutation?.("mkdir", path.dirname(params.outPath));
await assertDirectoryIdentityGuard(destinationGuard);
await targetRoot.mkdir(parentRel);
await assertDirectoryIdentityGuard(destinationGuard);
}
await assertResolvedInsideDestination({
destinationRealDir: params.destinationRealDir,
targetPath: parentDir,
targetPath: path.dirname(params.outPath),
originalPath: params.originalPath,
});
}
async function chmodInsideDestinationBestEffort(params: {
destinationRealDir: string;
destinationPath: string;
mode: number;
originalPath: string;
}): Promise<void> {
await getFsSafeTestHooks()?.beforeArchiveOutputMutation?.("chmod", params.destinationPath);
const destinationGuard = await createDirectoryIdentityGuard(params.destinationRealDir);
await assertDirectoryIdentityGuard(destinationGuard);
const noFollowFlag =
process.platform !== "win32" && "O_NOFOLLOW" in fsSync.constants
? fsSync.constants.O_NOFOLLOW
: 0;
const handle = await fs
.open(params.destinationPath, fsSync.constants.O_RDONLY | noFollowFlag)
.catch(() => null);
if (!handle) {
const stat = await fs.lstat(params.destinationPath).catch(() => null);
if (stat?.isSymbolicLink()) {
throw symlinkTraversalError(params.originalPath);
}
return;
}
try {
const stat = await handle.stat();
if (!stat.isDirectory() && !stat.isFile()) {
return;
}
const realPath = await resolveOpenedFileRealPathForHandle(handle, params.destinationPath);
if (!isPathInside(params.destinationRealDir, realPath)) {
throw symlinkTraversalError(params.originalPath);
}
await handle.chmod(params.mode).catch(() => undefined);
await assertDirectoryIdentityGuard(destinationGuard);
} finally {
await handle.close().catch(() => undefined);
}
}
async function applyStagedEntryMode(params: {
destinationRealDir: string;
relPath: string;
@ -133,7 +216,12 @@ async function applyStagedEntryMode(params: {
originalPath: params.originalPath,
});
if (params.mode !== 0) {
await fs.chmod(destinationPath, params.mode).catch(() => undefined);
await chmodInsideDestinationBestEffort({
destinationRealDir: params.destinationRealDir,
destinationPath,
mode: params.mode,
originalPath: params.originalPath,
});
}
}