328 lines
13 KiB
TypeScript
328 lines
13 KiB
TypeScript
/**
|
|
* Workspace file browsing: list directory / read
|
|
* file (preview & download) / write file (upload). Security: a relative path, once
|
|
* resolved, must stay inside the Workspace — a logical prefix check plus a realpath
|
|
* check against the nearest existing ancestor (guards against `..` and symlink escapes).
|
|
*/
|
|
import fs from "node:fs/promises";
|
|
import { constants as fsc } from "node:fs";
|
|
import path from "node:path";
|
|
import type { WorkspaceFileEntry, WorkspaceFilesResponse } from "../api/types.js";
|
|
import { HttpError } from "../http/errors.js";
|
|
import { badRequest } from "../http/validate.js";
|
|
|
|
/** Per-file read cap (a safety limit since preview/download reads the whole file into memory). */
|
|
const MAX_READ_BYTES = 50 * 1024 * 1024;
|
|
/** Upload cap (stays within the 20MB request body limit even after base64 encoding). */
|
|
export const MAX_UPLOAD_BYTES = 14 * 1024 * 1024;
|
|
|
|
const CONTENT_TYPES: Record<string, string> = {
|
|
".html": "text/html; charset=utf-8",
|
|
".htm": "text/html; charset=utf-8",
|
|
".txt": "text/plain; charset=utf-8",
|
|
".md": "text/markdown; charset=utf-8",
|
|
".json": "application/json",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".ts": "text/plain; charset=utf-8",
|
|
".tsx": "text/plain; charset=utf-8",
|
|
".py": "text/plain; charset=utf-8",
|
|
".sh": "text/plain; charset=utf-8",
|
|
".yaml": "text/plain; charset=utf-8",
|
|
".yml": "text/plain; charset=utf-8",
|
|
".toml": "text/plain; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".csv": "text/plain; charset=utf-8",
|
|
".log": "text/plain; charset=utf-8",
|
|
".svg": "image/svg+xml",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".gif": "image/gif",
|
|
".webp": "image/webp",
|
|
".pdf": "application/pdf",
|
|
};
|
|
|
|
export interface WorkspaceFileContent {
|
|
data: Buffer;
|
|
fileName: string;
|
|
contentType: string;
|
|
/** Types whose same-origin inline rendering would execute scripts (html/svg): inline preview must fall back to plain text. */
|
|
scriptable: boolean;
|
|
/** True when a bounded preview returned only the beginning of the file. */
|
|
truncated?: boolean;
|
|
}
|
|
|
|
export interface WorkspaceFileReadOptions {
|
|
/** Return at most this many bytes. Used for bounded text previews. */
|
|
maxBytes?: number;
|
|
}
|
|
|
|
export class WorkspaceFilesService {
|
|
/** Canonical path (realpath) of the Workspace root; 404 if it doesn't exist. */
|
|
private async realBase(workspace: string): Promise<string> {
|
|
try {
|
|
return await fs.realpath(path.resolve(workspace));
|
|
} catch {
|
|
throw new HttpError(404, "workspace_missing", "This Session's Workspace no longer exists.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lexical containment check: whether target is inside base (including equal to
|
|
* base). Uses path.relative rather than prefix concatenation, so it works when
|
|
* base is the filesystem root ("/" concatenated with sep would produce a "//"
|
|
* prefix that no subpath could ever match); only a full ".." segment is
|
|
* compared, so a legitimate name like "..foo" isn't mistakenly rejected.
|
|
*/
|
|
private isInside(target: string, base: string): boolean {
|
|
const rel = path.relative(base, target);
|
|
return rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
}
|
|
|
|
/** Lexical prefix check (a relative path, once resolved, must still be inside the Workspace); returns the absolute target path. */
|
|
private lexicalTarget(base: string, rel: string): string {
|
|
if (rel.includes("\0")) throw badRequest("path is invalid.");
|
|
const target = path.resolve(base, rel === "" ? "." : rel);
|
|
if (!this.isInside(target, base)) {
|
|
throw badRequest("path must be inside the Workspace.");
|
|
}
|
|
return target;
|
|
}
|
|
|
|
private assertInside(real: string, realBase: string): void {
|
|
if (!this.isInside(real, realBase)) {
|
|
throw badRequest("path must be inside the Workspace.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read-path resolution: realpath the entire path (following all symlinks to get
|
|
* a link-free canonical path), then check containment and **perform IO on the
|
|
* canonical path** — since the canonical path contains no symlink segments at
|
|
* all, this eliminates check-then-use TOCTOU escapes (an out-of-bounds symlink
|
|
* is already resolved and rejected at the realpath step).
|
|
*/
|
|
private async resolveRead(workspace: string, rel: string): Promise<string> {
|
|
const realBase = await this.realBase(workspace);
|
|
const target = this.lexicalTarget(path.resolve(workspace), rel);
|
|
let canonical: string;
|
|
try {
|
|
canonical = await fs.realpath(target);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw new HttpError(404, "path_not_found", "File does not exist.");
|
|
}
|
|
throw err;
|
|
}
|
|
this.assertInside(canonical, realBase);
|
|
return canonical;
|
|
}
|
|
|
|
/**
|
|
* Write-path resolution: realpaths the parent directory (whose canonical path
|
|
* has no symlink segments) and checks containment, then appends the final
|
|
* segment as the file name. When the parent directory is missing, it is safely
|
|
* created (uploading a folder needs to preserve directory structure): first the
|
|
* nearest **existing** ancestor is found and its canonical path checked against
|
|
* the Workspace — this exposes it if a middle segment was preset as a symlink
|
|
* pointing outside; the missing segments are then created recursively beneath it
|
|
* (a brand-new directory can never be a symlink), followed by a second realpath
|
|
* check after creation. The actual write opens with O_NOFOLLOW (refusing to
|
|
* follow a symlink at the final segment), blocking the sandbox-escape pattern of
|
|
* "Agent presets a symlink -> an upload is used as leverage to overwrite a file
|
|
* outside the sandbox". Returns the canonical parent directory + file name.
|
|
*/
|
|
private async resolveWriteParent(
|
|
workspace: string,
|
|
rel: string,
|
|
): Promise<{ dir: string; name: string }> {
|
|
const realBase = await this.realBase(workspace);
|
|
const target = this.lexicalTarget(path.resolve(workspace), rel);
|
|
const name = path.basename(target);
|
|
if (name === "" || name === "." || name === "..") throw badRequest("path must be a file path.");
|
|
const parent = path.dirname(target);
|
|
let canonicalParent: string;
|
|
try {
|
|
canonicalParent = await fs.realpath(parent);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
let probe = parent;
|
|
while (true) {
|
|
try {
|
|
this.assertInside(await fs.realpath(probe), realBase);
|
|
break;
|
|
} catch (probeErr) {
|
|
if ((probeErr as NodeJS.ErrnoException).code !== "ENOENT") throw probeErr;
|
|
const up = path.dirname(probe);
|
|
if (up === probe) throw badRequest("path is invalid.");
|
|
probe = up;
|
|
}
|
|
}
|
|
await fs.mkdir(parent, { recursive: true });
|
|
canonicalParent = await fs.realpath(parent);
|
|
}
|
|
this.assertInside(canonicalParent, realBase);
|
|
return { dir: canonicalParent, name };
|
|
}
|
|
|
|
/**
|
|
* Batch existence check (a message's file card lists only files that actually
|
|
* exist): each item goes through the same containment resolution as reading
|
|
* (resolveRead); out-of-bounds, resolution failure, missing Workspace, or an
|
|
* irregular file are all treated as non-existent — the card scenario only asks
|
|
* "can this be opened", and throwing a 4xx would only add frontend branches while
|
|
* leaking containment details. Returns the deduplicated existing items in input order.
|
|
*/
|
|
async statExisting(workspace: string, rels: string[]): Promise<string[]> {
|
|
const unique = [...new Set(rels)];
|
|
const exists = await Promise.all(
|
|
unique.map(async (rel) => {
|
|
try {
|
|
const stat = await fs.stat(await this.resolveRead(workspace, rel));
|
|
return stat.isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}),
|
|
);
|
|
return unique.filter((_, i) => exists[i]);
|
|
}
|
|
|
|
/**
|
|
* List a directory: dirs come first, each group sorted by name. Entries whose
|
|
* canonical target leaves the Workspace are omitted, so listing cannot expose
|
|
* metadata for an out-of-bounds symlink.
|
|
*/
|
|
async list(workspace: string, rel: string): Promise<WorkspaceFilesResponse> {
|
|
const dir = await this.resolveRead(workspace, rel);
|
|
const realBase = await this.realBase(workspace);
|
|
let dirents;
|
|
try {
|
|
dirents = await fs.readdir(dir, { withFileTypes: true });
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw new HttpError(404, "path_not_found", "Directory does not exist.");
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOTDIR") {
|
|
throw badRequest("path is not a directory.");
|
|
}
|
|
throw err;
|
|
}
|
|
const listed = await Promise.all(
|
|
dirents.map(async (d): Promise<WorkspaceFileEntry | null> => {
|
|
try {
|
|
const canonical = await fs.realpath(path.join(dir, d.name));
|
|
this.assertInside(canonical, realBase);
|
|
const stat = await fs.stat(canonical);
|
|
return {
|
|
name: d.name,
|
|
kind: stat.isDirectory() ? "dir" : "file",
|
|
sizeBytes: stat.size,
|
|
mtime: stat.mtime.toISOString(),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}),
|
|
);
|
|
const entries = listed.filter((entry): entry is WorkspaceFileEntry => entry !== null);
|
|
entries.sort((a, b) =>
|
|
a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1,
|
|
);
|
|
return { path: rel, entries };
|
|
}
|
|
|
|
/** Read a file (preview/download): IO on the canonical path (resolveRead has already eliminated symlink escapes). */
|
|
async read(
|
|
workspace: string,
|
|
rel: string,
|
|
options?: WorkspaceFileReadOptions,
|
|
): Promise<WorkspaceFileContent> {
|
|
const file = await this.resolveRead(workspace, rel);
|
|
let stat;
|
|
try {
|
|
stat = await fs.stat(file);
|
|
} catch {
|
|
throw new HttpError(404, "path_not_found", "File does not exist.");
|
|
}
|
|
if (stat.isDirectory()) throw badRequest("path is a directory.");
|
|
const maxBytes = options?.maxBytes;
|
|
if (
|
|
maxBytes !== undefined &&
|
|
(!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_READ_BYTES)
|
|
) {
|
|
throw badRequest("maxBytes must be a positive integer within the read limit.");
|
|
}
|
|
if (maxBytes === undefined && stat.size > MAX_READ_BYTES) {
|
|
throw new HttpError(413, "file_too_large", "File exceeds the 50MB read limit.");
|
|
}
|
|
let data: Buffer;
|
|
let truncated = false;
|
|
if (maxBytes !== undefined && stat.size > maxBytes) {
|
|
const handle = await fs.open(file, "r");
|
|
try {
|
|
const buffer = Buffer.alloc(maxBytes);
|
|
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
|
|
data = buffer.subarray(0, bytesRead);
|
|
truncated = true;
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
} else {
|
|
data = await fs.readFile(file);
|
|
}
|
|
const ext = path.extname(file).toLowerCase();
|
|
return {
|
|
data,
|
|
fileName: path.basename(file),
|
|
contentType: CONTENT_TYPES[ext] ?? "application/octet-stream",
|
|
scriptable: ext === ".html" || ext === ".htm" || ext === ".svg",
|
|
...(truncated ? { truncated: true } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Write a file (upload, overwriting a same-named one). If the parent directory
|
|
* is missing, it's automatically created under sandbox checks (preserving
|
|
* directory structure for folder uploads); the final segment is opened with
|
|
* O_NOFOLLOW, refusing to follow a symlink to write outside the Workspace
|
|
* (together with resolveWriteParent's canonical-parent check, this blocks
|
|
* sandbox escapes).
|
|
*/
|
|
async write(workspace: string, rel: string, data: Buffer): Promise<void> {
|
|
if (rel === "" || rel.endsWith("/")) throw badRequest("path must be a file path.");
|
|
if (data.length > MAX_UPLOAD_BYTES) {
|
|
throw new HttpError(413, "file_too_large", "Uploaded file exceeds the 14MB limit.");
|
|
}
|
|
const { dir, name } = await this.resolveWriteParent(workspace, rel);
|
|
const file = path.join(dir, name);
|
|
// Windows has no O_NOFOLLOW (the `?? 0` below erases it), so the atomic ELOOP guard never
|
|
// fires there — refuse a final-segment symlink via lstat instead. Best effort (a link
|
|
// created between this check and the open wins the race), but it closes the practical
|
|
// "preset a symlink, overwrite an outside file by upload" escape; POSIX keeps the atomic
|
|
// open-time guarantee.
|
|
if (process.platform === "win32") {
|
|
const st = await fs.lstat(file).catch(() => null);
|
|
if (st?.isSymbolicLink()) throw badRequest("path must not be a symlink.");
|
|
}
|
|
// O_NOFOLLOW: open reports ELOOP if the final segment is a symlink, refusing to use it as leverage to overwrite a file outside the sandbox.
|
|
const flags = fsc.O_WRONLY | fsc.O_CREAT | fsc.O_TRUNC | (fsc.O_NOFOLLOW ?? 0);
|
|
let handle;
|
|
try {
|
|
handle = await fs.open(file, flags, 0o644);
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
if (code === "ELOOP") throw badRequest("path must not be a symlink.");
|
|
if (code === "ENOENT")
|
|
throw new HttpError(404, "path_not_found", "Parent directory does not exist.");
|
|
if (code === "EISDIR") throw badRequest("path is a directory.");
|
|
throw err;
|
|
}
|
|
try {
|
|
await handle.writeFile(data);
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
}
|