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

302 lines
11 KiB
TypeScript

import { constants } from "node:fs";
import { 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" = "boundary",
options?: ErrorOptions,
) {
super(message, options);
this.name = "WorkspaceFileBoundaryError";
}
}
export interface WorkspaceFileSnapshot {
readonly path: string;
readonly name: string;
readonly data: Buffer;
}
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,
): 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);
}
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
const data = await file.readFile();
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!;
}
/**
* 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,
): Promise<string> {
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: string | 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;
for await (const chunk of source) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
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 = join(workspace, ...components);
} 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!;
}
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;
}