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