trails/apps/journal/serve-static.ts
Ullrich Schäfer 26d45cf2cb fix(journal): don't crash the process on a malformed request URL
A request for path `//` (also `///`, `/\`, ...) makes `new URL(req.url,
base)` throw ERR_INVALID_URL. serveStatic runs synchronously inside the
createServer callback, so the throw is an uncaught exception that kills
the process. Docker (`unless-stopped`) restarts it, and a client looping
on `//` crash-loops the journal — a trivial unauthenticated DoS. This
fired the "Container restart loop" Grafana alert in production (journal
restarted ~10x in 6 minutes).

Guard the URL parse with try/catch and fall through to the React Router
handler, which 404s malformed paths cleanly (the same way it already
handles scanner probes like /root/.ssh/id_rsa).

Extract serveStatic into its own module so it can be unit-tested without
booting the HTTP server, and add a regression test covering the
malformed-path cases. Widen the journal vitest include to discover
co-located tests for root-level server infra.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:41:26 +02:00

55 lines
1.8 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from "node:http";
import { createReadStream, statSync } from "node:fs";
import { join, extname, resolve } from "node:path";
const CLIENT_DIR = resolve(import.meta.dirname, "build", "client");
const MIME: Record<string, string> = {
".js": "application/javascript",
".css": "text/css",
".html": "text/html",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
};
/**
* Serve a static asset from the client build. Returns true if the request
* was handled, false if it should fall through to the app request handler.
*
* Runs synchronously inside the HTTP server callback, so it must never
* throw: a thrown error here is an uncaught exception that crashes the
* whole process. Malformed request paths (e.g. `//`, `///`, `/\`) make
* `new URL` throw `ERR_INVALID_URL`; we catch that and fall through so
* React Router 404s the request instead of taking the server down.
*/
export function serveStatic(req: IncomingMessage, res: ServerResponse): boolean {
if (req.method !== "GET" && req.method !== "HEAD") return false;
let url: URL;
try {
url = new URL(req.url ?? "/", `http://${req.headers.host}`);
} catch {
return false;
}
const filePath = resolve(join(CLIENT_DIR, url.pathname));
if (!filePath.startsWith(CLIENT_DIR)) return false;
try {
if (!statSync(filePath).isFile()) return false;
} catch {
return false;
}
res.setHeader("Content-Type", MIME[extname(filePath)] ?? "application/octet-stream");
if (url.pathname.startsWith("/assets/")) {
res.setHeader("Cache-Control", "public, immutable, max-age=31536000");
}
createReadStream(filePath).pipe(res);
return true;
}