fix(archive): pin staged merge mutations
This commit is contained in:
parent
ee0eb18a6d
commit
4658071a89
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user