Harden file explorer symlink handling (#847)
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http";
|
import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http";
|
||||||
import { createReadStream, unlinkSync, existsSync } from "fs";
|
import { constants, existsSync, unlinkSync } from "fs";
|
||||||
import { stat } from "fs/promises";
|
import { open } from "fs/promises";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { hostname as getHostname } from "node:os";
|
import { hostname as getHostname } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -143,6 +143,8 @@ type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
|||||||
|
|
||||||
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
|
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
|
||||||
const REDACTED_LOG_VALUE = "[redacted]";
|
const REDACTED_LOG_VALUE = "[redacted]";
|
||||||
|
const DOWNLOAD_OPEN_FLAGS =
|
||||||
|
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
|
||||||
|
|
||||||
function formatHostForHttpUrl(host: string): string {
|
function formatHostForHttpUrl(host: string): string {
|
||||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||||
@@ -415,8 +417,10 @@ export async function createPaseoDaemon(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fileHandle: Awaited<ReturnType<typeof open>> | null = null;
|
||||||
try {
|
try {
|
||||||
const fileStats = await stat(entry.absolutePath);
|
fileHandle = await open(entry.absolutePath, DOWNLOAD_OPEN_FLAGS);
|
||||||
|
const fileStats = await fileHandle.stat();
|
||||||
if (!fileStats.isFile()) {
|
if (!fileStats.isFile()) {
|
||||||
res.status(404).json({ error: "File not found" });
|
res.status(404).json({ error: "File not found" });
|
||||||
return;
|
return;
|
||||||
@@ -425,9 +429,10 @@ export async function createPaseoDaemon(
|
|||||||
const safeFileName = entry.fileName.replace(/["\r\n]/g, "_");
|
const safeFileName = entry.fileName.replace(/["\r\n]/g, "_");
|
||||||
res.setHeader("Content-Type", entry.mimeType);
|
res.setHeader("Content-Type", entry.mimeType);
|
||||||
res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`);
|
res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`);
|
||||||
res.setHeader("Content-Length", entry.size.toString());
|
res.setHeader("Content-Length", fileStats.size.toString());
|
||||||
|
|
||||||
const stream = createReadStream(entry.absolutePath);
|
const stream = fileHandle.createReadStream();
|
||||||
|
fileHandle = null;
|
||||||
stream.on("error", (err) => {
|
stream.on("error", (err) => {
|
||||||
logger.error({ err }, "Failed to stream download");
|
logger.error({ err }, "Failed to stream download");
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
@@ -442,6 +447,8 @@ export async function createPaseoDaemon(
|
|||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(404).json({ error: "File not found" });
|
res.status(404).json({ error: "File not found" });
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
await fileHandle?.close().catch(() => undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// POSIX-only: symlink fixtures
|
// POSIX-only: symlink fixtures
|
||||||
/* eslint-disable max-nested-callbacks */
|
/* eslint-disable max-nested-callbacks */
|
||||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { listDirectoryEntries, readExplorerFile } from "./service.js";
|
import { getDownloadableFileInfo, listDirectoryEntries, readExplorerFile } from "./service.js";
|
||||||
import { isPlatform } from "../../test-utils/platform.js";
|
import { isPlatform } from "../../test-utils/platform.js";
|
||||||
|
|
||||||
async function createTempDir(prefix: string): Promise<string> {
|
async function createTempDir(prefix: string): Promise<string> {
|
||||||
@@ -55,4 +55,53 @@ describe.skipIf(isPlatform("win32"))("service POSIX-only", () => {
|
|||||||
await rm(outsideRoot, { recursive: true, force: true });
|
await rm(outsideRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips listed symlink entries that resolve outside the workspace", async () => {
|
||||||
|
const root = await createTempDir("paseo-file-explorer-");
|
||||||
|
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(path.join(root, "visible.txt"), "visible\n", "utf-8");
|
||||||
|
const externalFile = path.join(outsideRoot, "secret.txt");
|
||||||
|
await writeFile(externalFile, "top secret\n", "utf-8");
|
||||||
|
await symlink(externalFile, path.join(root, "secret-link.txt"));
|
||||||
|
|
||||||
|
const result = await listDirectoryEntries({ root });
|
||||||
|
|
||||||
|
const names = result.entries.map((entry) => entry.name);
|
||||||
|
expect(names).toContain("visible.txt");
|
||||||
|
expect(names).not.toContain("secret-link.txt");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
await rm(outsideRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses canonical paths for downloadable symlink targets inside the workspace", async () => {
|
||||||
|
const root = await createTempDir("paseo-file-explorer-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target = path.join(root, "safe.txt");
|
||||||
|
const link = path.join(root, "safe-link.txt");
|
||||||
|
await writeFile(target, "safe\n", "utf-8");
|
||||||
|
await symlink("safe.txt", link);
|
||||||
|
|
||||||
|
const file = await readExplorerFile({
|
||||||
|
root,
|
||||||
|
relativePath: "safe-link.txt",
|
||||||
|
});
|
||||||
|
const info = await getDownloadableFileInfo({
|
||||||
|
root,
|
||||||
|
relativePath: "safe-link.txt",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(file.path).toBe("safe-link.txt");
|
||||||
|
expect(file.content).toBe("safe\n");
|
||||||
|
expect(info.path).toBe("safe-link.txt");
|
||||||
|
expect(info.fileName).toBe("safe-link.txt");
|
||||||
|
expect(info.absolutePath).toBe(await realpath(target));
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { promises as fs } from "fs";
|
import { constants, promises as fs } from "fs";
|
||||||
|
import type { FileHandle } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { resolvePathFromBase } from "../path-utils.js";
|
import { resolvePathFromBase } from "../path-utils.js";
|
||||||
|
|
||||||
@@ -54,6 +55,10 @@ const TEXT_MIME_TYPES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_TEXT_MIME_TYPE = "text/plain";
|
const DEFAULT_TEXT_MIME_TYPE = "text/plain";
|
||||||
|
const FILE_TYPE_SAMPLE_BYTES = 8192;
|
||||||
|
const READ_FILE_OPEN_FLAGS =
|
||||||
|
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
|
||||||
|
const ACCESS_OUTSIDE_WORKSPACE_MESSAGE = "Access outside of workspace is not allowed";
|
||||||
|
|
||||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
@@ -69,6 +74,11 @@ interface ScopedPathParams {
|
|||||||
relativePath?: string;
|
relativePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ScopedPath {
|
||||||
|
requestedPath: string;
|
||||||
|
resolvedPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface EntryPayloadParams {
|
interface EntryPayloadParams {
|
||||||
root: string;
|
root: string;
|
||||||
targetPath: string;
|
targetPath: string;
|
||||||
@@ -81,17 +91,17 @@ export async function listDirectoryEntries({
|
|||||||
relativePath = ".",
|
relativePath = ".",
|
||||||
}: ListDirectoryParams): Promise<FileExplorerDirectory> {
|
}: ListDirectoryParams): Promise<FileExplorerDirectory> {
|
||||||
const directoryPath = await resolveScopedPath({ root, relativePath });
|
const directoryPath = await resolveScopedPath({ root, relativePath });
|
||||||
const stats = await fs.stat(directoryPath);
|
const stats = await fs.stat(directoryPath.resolvedPath);
|
||||||
|
|
||||||
if (!stats.isDirectory()) {
|
if (!stats.isDirectory()) {
|
||||||
throw new Error("Requested path is not a directory");
|
throw new Error("Requested path is not a directory");
|
||||||
}
|
}
|
||||||
|
|
||||||
const dirents = await fs.readdir(directoryPath, { withFileTypes: true });
|
const dirents = await fs.readdir(directoryPath.resolvedPath, { withFileTypes: true });
|
||||||
|
|
||||||
const entriesWithNulls = await Promise.all(
|
const entriesWithNulls = await Promise.all(
|
||||||
dirents.map(async (dirent) => {
|
dirents.map(async (dirent) => {
|
||||||
const targetPath = path.join(directoryPath, dirent.name);
|
const targetPath = path.join(directoryPath.requestedPath, dirent.name);
|
||||||
const kind: ExplorerEntryKind = dirent.isDirectory() ? "directory" : "file";
|
const kind: ExplorerEntryKind = dirent.isDirectory() ? "directory" : "file";
|
||||||
try {
|
try {
|
||||||
return await buildEntryPayload({
|
return await buildEntryPayload({
|
||||||
@@ -103,7 +113,7 @@ export async function listDirectoryEntries({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Directories can contain dangling links (e.g. AGENTS.md -> CLAUDE.md).
|
// Directories can contain dangling links (e.g. AGENTS.md -> CLAUDE.md).
|
||||||
// Skip entries whose targets disappeared instead of failing the whole listing.
|
// Skip entries whose targets disappeared instead of failing the whole listing.
|
||||||
if (isMissingEntryError(error)) {
|
if (isMissingEntryError(error) || isOutsideWorkspaceError(error)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -121,7 +131,7 @@ export async function listDirectoryEntries({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: normalizeRelativePath({ root, targetPath: directoryPath }),
|
path: normalizeRelativePath({ root, targetPath: directoryPath.requestedPath }),
|
||||||
entries,
|
entries,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -171,48 +181,53 @@ export async function readExplorerFileBytes({
|
|||||||
relativePath,
|
relativePath,
|
||||||
}: ReadFileParams): Promise<FileExplorerFileBytes> {
|
}: ReadFileParams): Promise<FileExplorerFileBytes> {
|
||||||
const filePath = await resolveScopedPath({ root, relativePath });
|
const filePath = await resolveScopedPath({ root, relativePath });
|
||||||
const stats = await fs.stat(filePath);
|
const handle = await openFileForRead(filePath.resolvedPath);
|
||||||
|
|
||||||
if (!stats.isFile()) {
|
try {
|
||||||
throw new Error("Requested path is not a file");
|
const stats = await handle.stat();
|
||||||
}
|
|
||||||
|
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
if (!stats.isFile()) {
|
||||||
const basePayload = {
|
throw new Error("Requested path is not a file");
|
||||||
path: normalizeRelativePath({ root, targetPath: filePath }),
|
}
|
||||||
size: stats.size,
|
|
||||||
modifiedAt: stats.mtime.toISOString(),
|
const ext = path.extname(filePath.resolvedPath).toLowerCase();
|
||||||
};
|
const basePayload = {
|
||||||
|
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
|
||||||
|
size: stats.size,
|
||||||
|
modifiedAt: stats.mtime.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = await handle.readFile();
|
||||||
|
if (ext in IMAGE_MIME_TYPES) {
|
||||||
|
return {
|
||||||
|
...basePayload,
|
||||||
|
kind: "image",
|
||||||
|
encoding: "binary",
|
||||||
|
bytes: buffer,
|
||||||
|
mimeType: IMAGE_MIME_TYPES[ext],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLikelyBinary(buffer)) {
|
||||||
|
return {
|
||||||
|
...basePayload,
|
||||||
|
kind: "binary",
|
||||||
|
encoding: "binary",
|
||||||
|
bytes: buffer,
|
||||||
|
mimeType: "application/octet-stream",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (ext in IMAGE_MIME_TYPES) {
|
|
||||||
const buffer = await fs.readFile(filePath);
|
|
||||||
return {
|
return {
|
||||||
...basePayload,
|
...basePayload,
|
||||||
kind: "image",
|
kind: "text",
|
||||||
encoding: "binary",
|
encoding: "utf-8",
|
||||||
bytes: buffer,
|
bytes: buffer,
|
||||||
mimeType: IMAGE_MIME_TYPES[ext],
|
mimeType: textMimeTypeForExtension(ext),
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = await fs.readFile(filePath);
|
|
||||||
if (isLikelyBinary(buffer)) {
|
|
||||||
return {
|
|
||||||
...basePayload,
|
|
||||||
kind: "binary",
|
|
||||||
encoding: "binary",
|
|
||||||
bytes: buffer,
|
|
||||||
mimeType: "application/octet-stream",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...basePayload,
|
|
||||||
kind: "text",
|
|
||||||
encoding: "utf-8",
|
|
||||||
bytes: buffer,
|
|
||||||
mimeType: textMimeTypeForExtension(ext),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDownloadableFileInfo({ root, relativePath }: ReadFileParams): Promise<{
|
export async function getDownloadableFileInfo({ root, relativePath }: ReadFileParams): Promise<{
|
||||||
@@ -223,47 +238,50 @@ export async function getDownloadableFileInfo({ root, relativePath }: ReadFilePa
|
|||||||
size: number;
|
size: number;
|
||||||
}> {
|
}> {
|
||||||
const filePath = await resolveScopedPath({ root, relativePath });
|
const filePath = await resolveScopedPath({ root, relativePath });
|
||||||
const stats = await fs.stat(filePath);
|
const handle = await openFileForRead(filePath.resolvedPath);
|
||||||
|
|
||||||
if (!stats.isFile()) {
|
try {
|
||||||
throw new Error("Requested path is not a file");
|
const stats = await handle.stat();
|
||||||
}
|
|
||||||
|
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
if (!stats.isFile()) {
|
||||||
let mimeType = "application/octet-stream";
|
throw new Error("Requested path is not a file");
|
||||||
if (ext in IMAGE_MIME_TYPES) {
|
}
|
||||||
mimeType = IMAGE_MIME_TYPES[ext];
|
|
||||||
} else {
|
const ext = path.extname(filePath.resolvedPath).toLowerCase();
|
||||||
// Read only a small prefix to classify likely text vs binary.
|
let mimeType = "application/octet-stream";
|
||||||
const handle = await fs.open(filePath, "r");
|
if (ext in IMAGE_MIME_TYPES) {
|
||||||
const sample = Buffer.alloc(8192);
|
mimeType = IMAGE_MIME_TYPES[ext];
|
||||||
try {
|
} else {
|
||||||
|
const sample = Buffer.alloc(FILE_TYPE_SAMPLE_BYTES);
|
||||||
const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
|
const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
|
||||||
const chunk = bytesRead < sample.length ? sample.subarray(0, bytesRead) : sample;
|
const chunk = bytesRead < sample.length ? sample.subarray(0, bytesRead) : sample;
|
||||||
if (!isLikelyBinary(chunk)) {
|
if (!isLikelyBinary(chunk)) {
|
||||||
mimeType = textMimeTypeForExtension(ext);
|
mimeType = textMimeTypeForExtension(ext);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: normalizeRelativePath({ root, targetPath: filePath }),
|
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
|
||||||
absolutePath: filePath,
|
absolutePath: filePath.resolvedPath,
|
||||||
fileName: path.basename(filePath),
|
fileName: path.basename(filePath.requestedPath),
|
||||||
mimeType,
|
mimeType,
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveScopedPath({ root, relativePath = "." }: ScopedPathParams): Promise<string> {
|
async function resolveScopedPath({
|
||||||
|
root,
|
||||||
|
relativePath = ".",
|
||||||
|
}: ScopedPathParams): Promise<ScopedPath> {
|
||||||
const normalizedRoot = path.resolve(root);
|
const normalizedRoot = path.resolve(root);
|
||||||
const requestedPath = resolvePathFromBase(normalizedRoot, relativePath);
|
const requestedPath = resolvePathFromBase(normalizedRoot, relativePath);
|
||||||
const relative = path.relative(normalizedRoot, requestedPath);
|
const relative = path.relative(normalizedRoot, requestedPath);
|
||||||
|
|
||||||
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) {
|
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) {
|
||||||
throw new Error("Access outside of workspace is not allowed");
|
throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const realRoot = await fs.realpath(normalizedRoot);
|
const realRoot = await fs.realpath(normalizedRoot);
|
||||||
@@ -272,24 +290,32 @@ async function resolveScopedPath({ root, relativePath = "." }: ScopedPathParams)
|
|||||||
const realPath = await fs.realpath(requestedPath);
|
const realPath = await fs.realpath(requestedPath);
|
||||||
const realRelative = path.relative(realRoot, realPath);
|
const realRelative = path.relative(realRoot, realPath);
|
||||||
if (realRelative !== "" && (realRelative.startsWith("..") || path.isAbsolute(realRelative))) {
|
if (realRelative !== "" && (realRelative.startsWith("..") || path.isAbsolute(realRelative))) {
|
||||||
throw new Error("Access outside of workspace is not allowed");
|
throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
|
||||||
}
|
}
|
||||||
return requestedPath;
|
return { requestedPath, resolvedPath: realPath };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMissingEntryError(error)) {
|
if (isMissingEntryError(error)) {
|
||||||
return requestedPath;
|
return { requestedPath, resolvedPath: requestedPath };
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openFileForRead(filePath: string): Promise<FileHandle> {
|
||||||
|
return fs.open(filePath, READ_FILE_OPEN_FLAGS);
|
||||||
|
}
|
||||||
|
|
||||||
async function buildEntryPayload({
|
async function buildEntryPayload({
|
||||||
root,
|
root,
|
||||||
targetPath,
|
targetPath,
|
||||||
name,
|
name,
|
||||||
kind,
|
kind,
|
||||||
}: EntryPayloadParams): Promise<FileExplorerEntry> {
|
}: EntryPayloadParams): Promise<FileExplorerEntry> {
|
||||||
const stats = await fs.stat(targetPath);
|
const entryPath = await resolveScopedPath({
|
||||||
|
root,
|
||||||
|
relativePath: normalizeRelativePath({ root, targetPath }),
|
||||||
|
});
|
||||||
|
const stats = await fs.stat(entryPath.resolvedPath);
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
path: normalizeRelativePath({ root, targetPath }),
|
path: normalizeRelativePath({ root, targetPath }),
|
||||||
@@ -304,6 +330,10 @@ function isMissingEntryError(error: unknown): boolean {
|
|||||||
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
|
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOutsideWorkspaceError(error: unknown): boolean {
|
||||||
|
return error instanceof Error && error.message === ACCESS_OUTSIDE_WORKSPACE_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRelativePath({ root, targetPath }: { root: string; targetPath: string }): string {
|
function normalizeRelativePath({ root, targetPath }: { root: string; targetPath: string }): string {
|
||||||
const normalizedRoot = path.resolve(root);
|
const normalizedRoot = path.resolve(root);
|
||||||
const normalizedTarget = path.resolve(targetPath);
|
const normalizedTarget = path.resolve(targetPath);
|
||||||
|
|||||||
Reference in New Issue
Block a user