fix: harden file serving

This commit is contained in:
Peter Steinberger 2026-01-26 20:05:03 +00:00
parent 8b56f0e68d
commit 5eee991913
5 changed files with 213 additions and 50 deletions

View File

@ -8,6 +8,7 @@ import type { Duplex } from "node:stream";
import chokidar from "chokidar"; import chokidar from "chokidar";
import { type WebSocket, WebSocketServer } from "ws"; import { type WebSocket, WebSocketServer } from "ws";
import { isTruthyEnvValue } from "../infra/env.js"; import { isTruthyEnvValue } from "../infra/env.js";
import { SafeOpenError, openFileWithinRoot } from "../infra/fs-safe.js";
import { detectMime } from "../media/mime.js"; import { detectMime } from "../media/mime.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { ensureDir, resolveUserPath } from "../utils.js"; import { ensureDir, resolveUserPath } from "../utils.js";
@ -145,30 +146,31 @@ async function resolveFilePath(rootReal: string, urlPath: string) {
const rel = normalized.replace(/^\/+/, ""); const rel = normalized.replace(/^\/+/, "");
if (rel.split("/").some((p) => p === "..")) return null; if (rel.split("/").some((p) => p === "..")) return null;
let candidate = path.join(rootReal, rel); const tryOpen = async (relative: string) => {
try {
return await openFileWithinRoot({ rootDir: rootReal, relativePath: relative });
} catch (err) {
if (err instanceof SafeOpenError) return null;
throw err;
}
};
if (normalized.endsWith("/")) { if (normalized.endsWith("/")) {
candidate = path.join(candidate, "index.html"); return await tryOpen(path.posix.join(rel, "index.html"));
} }
const candidate = path.join(rootReal, rel);
try { try {
const st = await fs.stat(candidate); const st = await fs.lstat(candidate);
if (st.isSymbolicLink()) return null;
if (st.isDirectory()) { if (st.isDirectory()) {
candidate = path.join(candidate, "index.html"); return await tryOpen(path.posix.join(rel, "index.html"));
} }
} catch { } catch {
// ignore // ignore
} }
const rootPrefix = rootReal.endsWith(path.sep) ? rootReal : `${rootReal}${path.sep}`; return await tryOpen(rel);
try {
const lstat = await fs.lstat(candidate);
if (lstat.isSymbolicLink()) return null;
const real = await fs.realpath(candidate);
if (!real.startsWith(rootPrefix)) return null;
return real;
} catch {
return null;
}
} }
function isDisabledByEnv() { function isDisabledByEnv() {
@ -311,8 +313,8 @@ export async function createCanvasHostHandler(
return true; return true;
} }
const filePath = await resolveFilePath(rootReal, urlPath); const opened = await resolveFilePath(rootReal, urlPath);
if (!filePath) { if (!opened) {
if (urlPath === "/" || urlPath.endsWith("/")) { if (urlPath === "/" || urlPath.endsWith("/")) {
res.statusCode = 404; res.statusCode = 404;
res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Content-Type", "text/html; charset=utf-8");
@ -327,22 +329,30 @@ export async function createCanvasHostHandler(
return true; return true;
} }
const lower = filePath.toLowerCase(); const { handle, realPath } = opened;
let data: Buffer;
try {
data = await handle.readFile();
} finally {
await handle.close().catch(() => {});
}
const lower = realPath.toLowerCase();
const mime = const mime =
lower.endsWith(".html") || lower.endsWith(".htm") lower.endsWith(".html") || lower.endsWith(".htm")
? "text/html" ? "text/html"
: ((await detectMime({ filePath })) ?? "application/octet-stream"); : ((await detectMime({ filePath: realPath })) ?? "application/octet-stream");
res.setHeader("Cache-Control", "no-store"); res.setHeader("Cache-Control", "no-store");
if (mime === "text/html") { if (mime === "text/html") {
const html = await fs.readFile(filePath, "utf8"); const html = data.toString("utf8");
res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(liveReload ? injectCanvasLiveReload(html) : html); res.end(liveReload ? injectCanvasLiveReload(html) : html);
return true; return true;
} }
res.setHeader("Content-Type", mime); res.setHeader("Content-Type", mime);
res.end(await fs.readFile(filePath)); res.end(data);
return true; return true;
} catch (err) { } catch (err) {
opts.runtime.error(`canvasHost request failed: ${String(err)}`); opts.runtime.error(`canvasHost request failed: ${String(err)}`);

103
src/infra/fs-safe.ts Normal file
View File

@ -0,0 +1,103 @@
import { constants as fsConstants } from "node:fs";
import type { Stats } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import fs from "node:fs/promises";
import path from "node:path";
export type SafeOpenErrorCode = "invalid-path" | "not-found";
export class SafeOpenError extends Error {
code: SafeOpenErrorCode;
constructor(code: SafeOpenErrorCode, message: string) {
super(message);
this.code = code;
this.name = "SafeOpenError";
}
}
export type SafeOpenResult = {
handle: FileHandle;
realPath: string;
stat: Stats;
};
const NOT_FOUND_CODES = new Set(["ENOENT", "ENOTDIR"]);
const ensureTrailingSep = (value: string) => (value.endsWith(path.sep) ? value : value + path.sep);
const isNodeError = (err: unknown): err is NodeJS.ErrnoException =>
Boolean(err && typeof err === "object" && "code" in (err as Record<string, unknown>));
const isNotFoundError = (err: unknown) =>
isNodeError(err) && typeof err.code === "string" && NOT_FOUND_CODES.has(err.code);
const isSymlinkOpenError = (err: unknown) =>
isNodeError(err) && (err.code === "ELOOP" || err.code === "EINVAL" || err.code === "ENOTSUP");
export async function openFileWithinRoot(params: {
rootDir: string;
relativePath: string;
}): Promise<SafeOpenResult> {
let rootReal: string;
try {
rootReal = await fs.realpath(params.rootDir);
} catch (err) {
if (isNotFoundError(err)) {
throw new SafeOpenError("not-found", "root dir not found");
}
throw err;
}
const rootWithSep = ensureTrailingSep(rootReal);
const resolved = path.resolve(rootWithSep, params.relativePath);
if (!resolved.startsWith(rootWithSep)) {
throw new SafeOpenError("invalid-path", "path escapes root");
}
const supportsNoFollow = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
const flags = fsConstants.O_RDONLY | (supportsNoFollow ? (fsConstants.O_NOFOLLOW as number) : 0);
let handle: FileHandle;
try {
handle = await fs.open(resolved, flags);
} catch (err) {
if (isNotFoundError(err)) {
throw new SafeOpenError("not-found", "file not found");
}
if (isSymlinkOpenError(err)) {
throw new SafeOpenError("invalid-path", "symlink open blocked");
}
throw err;
}
try {
const lstat = await fs.lstat(resolved).catch(() => null);
if (lstat?.isSymbolicLink()) {
throw new SafeOpenError("invalid-path", "symlink not allowed");
}
const realPath = await fs.realpath(resolved);
if (!realPath.startsWith(rootWithSep)) {
throw new SafeOpenError("invalid-path", "path escapes root");
}
const stat = await handle.stat();
if (!stat.isFile()) {
throw new SafeOpenError("invalid-path", "not a file");
}
const realStat = await fs.stat(realPath);
if (stat.ino !== realStat.ino || stat.dev !== realStat.dev) {
throw new SafeOpenError("invalid-path", "path mismatch");
}
return { handle, realPath, stat };
} catch (err) {
await handle.close().catch(() => {});
if (err instanceof SafeOpenError) throw err;
if (isNotFoundError(err)) {
throw new SafeOpenError("not-found", "file not found");
}
throw err;
}
}

View File

@ -7,12 +7,17 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const MEDIA_DIR = path.join(process.cwd(), "tmp-media-test"); const MEDIA_DIR = path.join(process.cwd(), "tmp-media-test");
const cleanOldMedia = vi.fn().mockResolvedValue(undefined); const cleanOldMedia = vi.fn().mockResolvedValue(undefined);
vi.mock("./store.js", () => ({ vi.mock("./store.js", async (importOriginal) => {
getMediaDir: () => MEDIA_DIR, const actual = await importOriginal<typeof import("./store.js")>();
cleanOldMedia, return {
})); ...actual,
getMediaDir: () => MEDIA_DIR,
cleanOldMedia,
};
});
const { startMediaServer } = await import("./server.js"); const { startMediaServer } = await import("./server.js");
const { MEDIA_MAX_BYTES } = await import("./store.js");
const waitForFileRemoval = async (file: string, timeoutMs = 200) => { const waitForFileRemoval = async (file: string, timeoutMs = 200) => {
const start = Date.now(); const start = Date.now();
@ -84,4 +89,27 @@ describe("media server", () => {
expect(await res.text()).toBe("invalid path"); expect(await res.text()).toBe("invalid path");
await new Promise((r) => server.close(r)); await new Promise((r) => server.close(r));
}); });
it("rejects invalid media ids", async () => {
const file = path.join(MEDIA_DIR, "file2");
await fs.writeFile(file, "hello");
const server = await startMediaServer(0, 5_000);
const port = (server.address() as AddressInfo).port;
const res = await fetch(`http://localhost:${port}/media/invalid%20id`);
expect(res.status).toBe(400);
expect(await res.text()).toBe("invalid path");
await new Promise((r) => server.close(r));
});
it("rejects oversized media files", async () => {
const file = path.join(MEDIA_DIR, "big");
await fs.writeFile(file, "");
await fs.truncate(file, MEDIA_MAX_BYTES + 1);
const server = await startMediaServer(0, 5_000);
const port = (server.address() as AddressInfo).port;
const res = await fetch(`http://localhost:${port}/media/big`);
expect(res.status).toBe(413);
expect(await res.text()).toBe("too large");
await new Promise((r) => server.close(r));
});
}); });

View File

@ -1,13 +1,23 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import type { Server } from "node:http"; import type { Server } from "node:http";
import path from "node:path";
import express, { type Express } from "express"; import express, { type Express } from "express";
import { danger } from "../globals.js"; import { danger } from "../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { SafeOpenError, openFileWithinRoot } from "../infra/fs-safe.js";
import { detectMime } from "./mime.js"; import { detectMime } from "./mime.js";
import { cleanOldMedia, getMediaDir } from "./store.js"; import { cleanOldMedia, getMediaDir, MEDIA_MAX_BYTES } from "./store.js";
const DEFAULT_TTL_MS = 2 * 60 * 1000; const DEFAULT_TTL_MS = 2 * 60 * 1000;
const MAX_MEDIA_ID_CHARS = 200;
const MEDIA_ID_PATTERN = /^[\p{L}\p{N}._-]+$/u;
const MAX_MEDIA_BYTES = MEDIA_MAX_BYTES;
const isValidMediaId = (id: string) => {
if (!id) return false;
if (id.length > MAX_MEDIA_ID_CHARS) return false;
if (id === "." || id === "..") return false;
return MEDIA_ID_PATTERN.test(id);
};
export function attachMediaRoutes( export function attachMediaRoutes(
app: Express, app: Express,
@ -18,26 +28,28 @@ export function attachMediaRoutes(
app.get("/media/:id", async (req, res) => { app.get("/media/:id", async (req, res) => {
const id = req.params.id; const id = req.params.id;
const mediaRoot = (await fs.realpath(mediaDir)) + path.sep; if (!isValidMediaId(id)) {
const file = path.resolve(mediaRoot, id); res.status(400).send("invalid path");
return;
}
try { try {
const lstat = await fs.lstat(file); const { handle, realPath, stat } = await openFileWithinRoot({
if (lstat.isSymbolicLink()) { rootDir: mediaDir,
res.status(400).send("invalid path"); relativePath: id,
});
if (stat.size > MAX_MEDIA_BYTES) {
await handle.close().catch(() => {});
res.status(413).send("too large");
return; return;
} }
const realPath = await fs.realpath(file);
if (!realPath.startsWith(mediaRoot)) {
res.status(400).send("invalid path");
return;
}
const stat = await fs.stat(realPath);
if (Date.now() - stat.mtimeMs > ttlMs) { if (Date.now() - stat.mtimeMs > ttlMs) {
await handle.close().catch(() => {});
await fs.rm(realPath).catch(() => {}); await fs.rm(realPath).catch(() => {});
res.status(410).send("expired"); res.status(410).send("expired");
return; return;
} }
const data = await fs.readFile(realPath); const data = await handle.readFile();
await handle.close().catch(() => {});
const mime = await detectMime({ buffer: data, filePath: realPath }); const mime = await detectMime({ buffer: data, filePath: realPath });
if (mime) res.type(mime); if (mime) res.type(mime);
res.send(data); res.send(data);
@ -47,7 +59,17 @@ export function attachMediaRoutes(
fs.rm(realPath).catch(() => {}); fs.rm(realPath).catch(() => {});
}, 50); }, 50);
}); });
} catch { } catch (err) {
if (err instanceof SafeOpenError) {
if (err.code === "invalid-path") {
res.status(400).send("invalid path");
return;
}
if (err.code === "not-found") {
res.status(404).send("not found");
return;
}
}
res.status(404).send("not found"); res.status(404).send("not found");
} }
}); });

View File

@ -10,7 +10,8 @@ import { resolvePinnedHostname } from "../infra/net/ssrf.js";
import { detectMime, extensionForMime } from "./mime.js"; import { detectMime, extensionForMime } from "./mime.js";
const resolveMediaDir = () => path.join(resolveConfigDir(), "media"); const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default export const MEDIA_MAX_BYTES = 5 * 1024 * 1024; // 5MB default
const MAX_BYTES = MEDIA_MAX_BYTES;
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
/** /**
@ -19,10 +20,9 @@ const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
* Keeps: alphanumeric, dots, hyphens, underscores, Unicode letters/numbers. * Keeps: alphanumeric, dots, hyphens, underscores, Unicode letters/numbers.
*/ */
function sanitizeFilename(name: string): string { function sanitizeFilename(name: string): string {
// Remove: < > : " / \ | ? * and control chars (U+0000-U+001F) const trimmed = name.trim();
// oxlint-disable-next-line no-control-regex -- Intentionally matching control chars if (!trimmed) return "";
const unsafe = /[<>:"/\\|?*\x00-\x1f]/g; const sanitized = trimmed.replace(/[^\p{L}\p{N}._-]+/gu, "_");
const sanitized = name.trim().replace(unsafe, "_").replace(/\s+/g, "_"); // Replace whitespace runs with underscore
// Collapse multiple underscores, trim leading/trailing, limit length // Collapse multiple underscores, trim leading/trailing, limit length
return sanitized.replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 60); return sanitized.replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 60);
} }
@ -56,7 +56,7 @@ export function getMediaDir() {
export async function ensureMediaDir() { export async function ensureMediaDir() {
const mediaDir = resolveMediaDir(); const mediaDir = resolveMediaDir();
await fs.mkdir(mediaDir, { recursive: true }); await fs.mkdir(mediaDir, { recursive: true, mode: 0o700 });
return mediaDir; return mediaDir;
} }
@ -123,7 +123,7 @@ async function downloadToFile(
let total = 0; let total = 0;
const sniffChunks: Buffer[] = []; const sniffChunks: Buffer[] = [];
let sniffLen = 0; let sniffLen = 0;
const out = createWriteStream(dest); const out = createWriteStream(dest, { mode: 0o600 });
res.on("data", (chunk) => { res.on("data", (chunk) => {
total += chunk.length; total += chunk.length;
if (sniffLen < 16384) { if (sniffLen < 16384) {
@ -168,7 +168,7 @@ export async function saveMediaSource(
): Promise<SavedMedia> { ): Promise<SavedMedia> {
const baseDir = resolveMediaDir(); const baseDir = resolveMediaDir();
const dir = subdir ? path.join(baseDir, subdir) : baseDir; const dir = subdir ? path.join(baseDir, subdir) : baseDir;
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true, mode: 0o700 });
await cleanOldMedia(); await cleanOldMedia();
const baseId = crypto.randomUUID(); const baseId = crypto.randomUUID();
if (looksLikeUrl(source)) { if (looksLikeUrl(source)) {
@ -198,7 +198,7 @@ export async function saveMediaSource(
const ext = extensionForMime(mime) ?? path.extname(source); const ext = extensionForMime(mime) ?? path.extname(source);
const id = ext ? `${baseId}${ext}` : baseId; const id = ext ? `${baseId}${ext}` : baseId;
const dest = path.join(dir, id); const dest = path.join(dir, id);
await fs.writeFile(dest, buffer); await fs.writeFile(dest, buffer, { mode: 0o600 });
return { id, path: dest, size: stat.size, contentType: mime }; return { id, path: dest, size: stat.size, contentType: mime };
} }
@ -213,7 +213,7 @@ export async function saveMediaBuffer(
throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`); throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`);
} }
const dir = path.join(resolveMediaDir(), subdir); const dir = path.join(resolveMediaDir(), subdir);
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true, mode: 0o700 });
const uuid = crypto.randomUUID(); const uuid = crypto.randomUUID();
const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined); const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined);
const mime = await detectMime({ buffer, headerMime: contentType }); const mime = await detectMime({ buffer, headerMime: contentType });
@ -231,6 +231,6 @@ export async function saveMediaBuffer(
} }
const dest = path.join(dir, id); const dest = path.join(dir, id);
await fs.writeFile(dest, buffer); await fs.writeFile(dest, buffer, { mode: 0o600 });
return { id, path: dest, size: buffer.byteLength, contentType: mime }; return { id, path: dest, size: buffer.byteLength, contentType: mime };
} }