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>
This commit is contained in:
parent
3d7dfda45f
commit
26d45cf2cb
4 changed files with 98 additions and 38 deletions
35
apps/journal/serve-static.test.ts
Normal file
35
apps/journal/serve-static.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { serveStatic } from "./serve-static.ts";
|
||||
|
||||
function mockReq(url: string, method = "GET"): IncomingMessage {
|
||||
return { url, method, headers: { host: "trails.cool" } } as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
function mockRes(): ServerResponse {
|
||||
return { setHeader: vi.fn() } as unknown as ServerResponse;
|
||||
}
|
||||
|
||||
describe("serveStatic", () => {
|
||||
// Regression: a malformed path makes `new URL` throw ERR_INVALID_URL.
|
||||
// Because serveStatic runs synchronously inside the HTTP server
|
||||
// callback, an unhandled throw is process-fatal — a single `GET //`
|
||||
// crash-looped the journal in prod. It must fall through (return false)
|
||||
// so the request reaches React Router and 404s instead.
|
||||
it.each(["//", "///", "/\\", "//foo\\bar"])(
|
||||
"returns false without throwing for malformed path %j",
|
||||
(path) => {
|
||||
const req = mockReq(path);
|
||||
expect(() => serveStatic(req, mockRes())).not.toThrow();
|
||||
expect(serveStatic(req, mockRes())).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("falls through for non-GET/HEAD methods", () => {
|
||||
expect(serveStatic(mockReq("/assets/app.js", "POST"), mockRes())).toBe(false);
|
||||
});
|
||||
|
||||
it("falls through for a well-formed path with no matching file", () => {
|
||||
expect(serveStatic(mockReq("/does/not/exist.js"), mockRes())).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue