Files
curriculum-project-hub/hub/src/security/workspaceFiles.ts
T

471 lines
16 KiB
TypeScript

import { constants } from "node:fs";
import { link, lstat, mkdir, open, realpath, unlink, type FileHandle } from "node:fs/promises";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import type { Readable } from "node:stream";
export class WorkspaceFileBoundaryError extends Error {
constructor(
message: string,
readonly requestedPath: string,
readonly reason: "not_found" | "boundary" | "io" | "limit" = "boundary",
options?: ErrorOptions,
) {
super(message, options);
this.name = "WorkspaceFileBoundaryError";
}
}
export interface WorkspaceFileSnapshot {
readonly path: string;
readonly name: string;
readonly data: Buffer;
}
export interface WorkspaceFileWriteResult {
readonly path: string;
readonly device: number;
readonly inode: number;
}
interface OpenDirectory {
readonly handle: FileHandle;
readonly displayPath: string;
}
/** Read a stable snapshot without following any symlink below the workspace. */
export async function readWorkspaceFileNoFollow(
workspaceRoot: string,
workspaceDir: string,
requestedPath: string,
maxBytes?: number,
): Promise<WorkspaceFileSnapshot> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath);
const name = components.at(-1)!;
const directories = components.slice(0, -1);
const parent = await openDirectoryTree(workspace, directories, false, requestedPath);
const openedPath = childPath(parent, name);
let file: FileHandle | undefined;
let result: WorkspaceFileSnapshot | undefined;
let failure: unknown;
try {
file = await open(openedPath, constants.O_RDONLY | constants.O_NOFOLLOW);
const metadata = await file.stat();
if (!metadata.isFile()) {
throw new WorkspaceFileBoundaryError(`deliverable is not a regular file: ${requestedPath}`, requestedPath);
}
if (maxBytes !== undefined && metadata.size > maxBytes) {
throw new WorkspaceFileBoundaryError(
`workspace file exceeds ${maxBytes} bytes: ${requestedPath}`,
requestedPath,
"limit",
);
}
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
const data = await readFileSnapshot(file, maxBytes, requestedPath);
result = { path: join(workspace, ...components), name, data };
} catch (error) {
failure = boundaryError(error, requestedPath, "cannot read workspace file without following symlinks");
}
await finishWithCleanup(
failure,
[file, parent.handle],
`workspace file read cleanup failed: ${requestedPath}`,
);
return result!;
}
async function readFileSnapshot(
file: FileHandle,
maxBytes: number | undefined,
requestedPath: string,
): Promise<Buffer> {
if (maxBytes === undefined) return file.readFile();
const chunks: Buffer[] = [];
let total = 0;
let position = 0;
while (true) {
const remainingWithSentinel = maxBytes - total + 1;
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, remainingWithSentinel));
const { bytesRead } = await file.read(chunk, 0, chunk.length, position);
if (bytesRead === 0) return Buffer.concat(chunks, total);
total += bytesRead;
if (total > maxBytes) {
throw new WorkspaceFileBoundaryError(
`workspace file exceeds ${maxBytes} bytes: ${requestedPath}`,
requestedPath,
"limit",
);
}
chunks.push(chunk.subarray(0, bytesRead));
position += bytesRead;
}
}
/**
* Create one inbound file exclusively below the workspace and stream into its
* already-open descriptor. Linux uses /proc/self/fd-relative traversal so a
* concurrent directory rename/symlink swap cannot redirect the write.
*/
export async function writeNewWorkspaceFileNoFollow(
workspaceRoot: string,
workspaceDir: string,
requestedPath: string,
source: Readable,
maxBytes?: number,
): Promise<string> {
return (await writeNewWorkspaceFileNoFollowTracked(
workspaceRoot,
workspaceDir,
requestedPath,
source,
maxBytes,
)).path;
}
/** Create one inbound file and return its identity for race-safe compensation. */
export async function writeNewWorkspaceFileNoFollowTracked(
workspaceRoot: string,
workspaceDir: string,
requestedPath: string,
source: Readable,
maxBytes?: number,
): Promise<WorkspaceFileWriteResult> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath);
const name = components.at(-1)!;
const directories = components.slice(0, -1);
const parent = await openDirectoryTree(workspace, directories, true, requestedPath);
const openedPath = childPath(parent, name);
let file: FileHandle | undefined;
let device: number | undefined;
let inode: number | undefined;
let result: WorkspaceFileWriteResult | undefined;
let failure: unknown;
const cleanupFailures: unknown[] = [];
try {
file = await open(
openedPath,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o600,
);
const metadata = await file.stat();
device = metadata.dev;
inode = metadata.ino;
let bytesWritten = 0;
for await (const chunk of source) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
bytesWritten += buffer.length;
if (maxBytes !== undefined && bytesWritten > maxBytes) {
throw new WorkspaceFileBoundaryError(
`inbound file exceeds ${maxBytes} bytes: ${requestedPath}`,
requestedPath,
"limit",
);
}
let offset = 0;
while (offset < buffer.length) {
const { bytesWritten } = await file.write(buffer, offset, buffer.length - offset, null);
if (bytesWritten === 0) throw new Error("inbound workspace write made no progress");
offset += bytesWritten;
}
}
await file.sync();
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
result = {
path: join(workspace, ...components),
device: metadata.dev,
inode: metadata.ino,
};
} catch (error) {
failure = boundaryError(error, requestedPath, "cannot create inbound workspace file safely");
if (device !== undefined && inode !== undefined) {
try {
await unlinkIfSameFile(parent, name, device, inode);
} catch (cleanupError) {
cleanupFailures.push(cleanupError);
}
}
}
await finishWithCleanup(
failure,
[file, parent.handle],
`inbound workspace file cleanup failed: ${requestedPath}`,
cleanupFailures,
);
return result!;
}
/** Remove only the exact file created by a tracked no-follow write. */
export async function removeWorkspaceFileIfUnchangedNoFollow(
workspaceRoot: string,
workspaceDir: string,
requestedPath: string,
expectedDevice: number,
expectedInode: number,
): Promise<void> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath);
const name = components.at(-1)!;
const parent = await openDirectoryTree(workspace, components.slice(0, -1), false, requestedPath);
let failure: unknown;
try {
await unlinkIfSameFile(parent, name, expectedDevice, expectedInode);
} catch (error) {
failure = boundaryError(error, requestedPath, "cannot compensate inbound workspace file safely");
}
await finishWithCleanup(
failure,
[parent.handle],
`inbound workspace compensation cleanup failed: ${requestedPath}`,
);
}
/**
* Publish a protected same-filesystem staging file with one exclusive hard-link
* operation. The target path is traversed without following workspace symlinks.
*/
export async function linkWorkspaceFileNoFollow(
workspaceRoot: string,
workspaceDir: string,
requestedPath: string,
stagedPath: string,
): Promise<WorkspaceFileWriteResult> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath);
const name = components.at(-1)!;
const parent = await openDirectoryTree(workspace, components.slice(0, -1), true, requestedPath);
let source: FileHandle | undefined;
let result: WorkspaceFileWriteResult | undefined;
let failure: unknown;
const cleanupFailures: unknown[] = [];
try {
source = await open(stagedPath, constants.O_RDONLY | constants.O_NOFOLLOW);
const metadata = await source.stat();
if (!metadata.isFile()) {
throw new WorkspaceFileBoundaryError(`staged resource is not a regular file: ${stagedPath}`, stagedPath);
}
const targetPath = childPath(parent, name);
await link(stagedPath, targetPath);
try {
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
} catch (error) {
await unlinkIfSameFile(parent, name, metadata.dev, metadata.ino).catch((cleanupError) => {
cleanupFailures.push(cleanupError);
});
throw error;
}
result = {
path: join(workspace, ...components),
device: metadata.dev,
inode: metadata.ino,
};
} catch (error) {
failure = boundaryError(error, requestedPath, "cannot publish staged workspace file safely");
}
try {
await finishWithCleanup(
failure,
[source, parent.handle],
`staged workspace publication cleanup failed: ${requestedPath}`,
cleanupFailures,
);
} catch (error) {
if (result !== undefined) {
try {
await removeWorkspaceFileIfUnchangedNoFollow(
workspaceRoot,
workspaceDir,
requestedPath,
result.device,
result.inode,
);
} catch (compensationError) {
throw new AggregateError(
[error, compensationError],
`staged workspace publication and compensation both failed: ${requestedPath}`,
{ cause: error },
);
}
}
throw error;
}
return result!;
}
async function canonicalWorkspace(workspaceRoot: string, workspaceDir: string): Promise<string> {
if (process.platform !== "linux") {
throw new WorkspaceFileBoundaryError(
"race-safe descriptor-relative workspace access requires Linux",
workspaceDir,
);
}
if (!isAbsolute(workspaceRoot) || !isAbsolute(workspaceDir)) {
throw new WorkspaceFileBoundaryError("workspace root and project path must be absolute", workspaceDir);
}
const configuredRoot = resolve(workspaceRoot);
const configuredWorkspace = resolve(workspaceDir);
const rel = relative(configuredRoot, configuredWorkspace);
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw new WorkspaceFileBoundaryError("project workspace escapes configured workspace root", workspaceDir);
}
const rootMetadata = await lstat(configuredRoot);
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
throw new WorkspaceFileBoundaryError("configured workspace root is not a real directory", workspaceRoot);
}
const root = await realpath(configuredRoot);
const components = rel.split(sep);
const openedWorkspace = await openDirectoryTree(root, components, false, workspaceDir);
let result: string | undefined;
let failure: unknown;
try {
const openedTarget = await realpath(`/proc/self/fd/${openedWorkspace.handle.fd}`);
const expected = join(root, ...components);
if (openedTarget !== expected) {
throw new WorkspaceFileBoundaryError("project workspace contains a symlink", workspaceDir);
}
result = expected;
} catch (error) {
failure = boundaryError(error, workspaceDir, "project workspace validation failed");
}
await finishWithCleanup(
failure,
[openedWorkspace.handle],
`project workspace validation cleanup failed: ${workspaceDir}`,
);
return result!;
}
function fileComponents(root: string, requestedPath: string): string[] {
const token = requestedPath.trim();
if (token === "" || token.includes("\0")) {
throw new WorkspaceFileBoundaryError("workspace file path is empty or invalid", requestedPath);
}
const absolute = isAbsolute(token) ? resolve(token) : resolve(root, token);
const rel = relative(root, absolute);
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw new WorkspaceFileBoundaryError(`workspace file path escapes current project: ${requestedPath}`, requestedPath);
}
const components = rel.split(/[\\/]/);
if (components.some((component) => component === "" || component === "." || component === "..")) {
throw new WorkspaceFileBoundaryError(`workspace file path is not normalized: ${requestedPath}`, requestedPath);
}
return components;
}
async function openDirectoryTree(
root: string,
components: readonly string[],
create: boolean,
requestedPath: string,
): Promise<OpenDirectory> {
let current: OpenDirectory = {
handle: await open(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW),
displayPath: root,
};
try {
for (const component of components) {
const path = childPath(current, component);
let next: FileHandle;
try {
next = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
} catch (error) {
if (!create || !isErrno(error, "ENOENT")) throw error;
try {
await mkdir(path, { mode: 0o700 });
} catch (mkdirError) {
if (!isErrno(mkdirError, "EEXIST")) throw mkdirError;
}
next = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
}
const previous = current;
current = { handle: next, displayPath: join(previous.displayPath, component) };
await previous.handle.close();
}
return current;
} catch (error) {
try {
await current.handle.close();
} catch (closeError) {
throw new AggregateError(
[error, closeError],
`workspace path validation and directory close both failed: ${requestedPath}`,
{ cause: error },
);
}
throw boundaryError(error, requestedPath, "workspace path contains a symlink or non-directory component");
}
}
function childPath(parent: OpenDirectory, name: string): string {
return `/proc/self/fd/${parent.handle.fd}/${name}`;
}
async function assertNameStillReferences(
parent: OpenDirectory,
name: string,
device: number,
inode: number,
requestedPath: string,
): Promise<void> {
const metadata = await lstat(childPath(parent, name));
if (metadata.isSymbolicLink() || metadata.dev !== device || metadata.ino !== inode) {
throw new WorkspaceFileBoundaryError(
`workspace file changed during no-follow access: ${requestedPath}`,
requestedPath,
);
}
}
async function unlinkIfSameFile(parent: OpenDirectory, name: string, device: number, inode: number): Promise<void> {
const path = childPath(parent, name);
try {
const metadata = await lstat(path);
if (!metadata.isSymbolicLink() && metadata.dev === device && metadata.ino === inode) {
await unlink(path);
}
} catch (error) {
if (!isErrno(error, "ENOENT")) throw error;
}
}
function boundaryError(error: unknown, requestedPath: string, prefix: string): WorkspaceFileBoundaryError {
if (error instanceof WorkspaceFileBoundaryError) return error;
const detail = error instanceof Error ? error.message : String(error);
const reason = isErrno(error, "ENOENT")
? "not_found"
: isErrno(error, "ELOOP") || isErrno(error, "ENOTDIR")
? "boundary"
: "io";
return new WorkspaceFileBoundaryError(
`${prefix}: ${requestedPath} (${detail})`,
requestedPath,
reason,
{ cause: error },
);
}
async function finishWithCleanup(
primaryFailure: unknown,
handles: ReadonlyArray<FileHandle | undefined>,
message: string,
additionalFailures: readonly unknown[] = [],
): Promise<void> {
const settled = await Promise.allSettled(
handles.filter((handle): handle is FileHandle => handle !== undefined).map((handle) => handle.close()),
);
const cleanupFailures = [
...additionalFailures,
...settled.flatMap((result) => result.status === "rejected" ? [result.reason] : []),
];
if (primaryFailure !== undefined && cleanupFailures.length === 0) throw primaryFailure;
if (primaryFailure !== undefined || cleanupFailures.length > 0) {
const failures = primaryFailure === undefined ? cleanupFailures : [primaryFailure, ...cleanupFailures];
throw new AggregateError(failures, message, { cause: primaryFailure ?? cleanupFailures[0] });
}
}
function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}