Public: anyone can read this; the crew can change it.
A HEAD of the front door is a 404 #193
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/180-a-head-of-the-front-door-is-a-404 → main 1 commit fork
2 files
changed +73
−1
src/http/server.ts +19 −1
| @@ -1159,16 +1159,34 @@ export async function serve(app: App): Promise<Servers> { | ||
| 1159 | 1159 | * matched or the two shapes of "no route", the limiter, and the handler. |
| 1160 | 1160 | * The response it returns has not been given the security floor yet — that |
| 1161 | 1161 | * is the caller's single door, above. |
| 1162 | * | |
| 1163 | * HEAD is answered here too, in one place: it routes as the GET it stands | |
| 1164 | * in for — so a path with only a POST route still 404s — and the body is | |
| 1165 | * dropped from whatever GET would have returned, after the one render that | |
| 1166 | * produced it. | |
| 1162 | 1167 | */ |
| 1163 | 1168 | async function answer( |
| 1164 | 1169 | app: App, |
| 1165 | 1170 | router: Router<Handler>, |
| 1166 | 1171 | limiter: RateLimiter, |
| 1167 | 1172 | request: Request, |
| 1173 | ): Promise<Response> { | |
| 1174 | const response = await answerGet(app, router, limiter, request); | |
| 1175 | return request.method === "HEAD" | |
| 1176 | ? new Response(null, { status: response.status, headers: response.headers }) | |
| 1177 | : response; | |
| 1178 | } | |
| 1179 | ||
| 1180 | async function answerGet( | |
| 1181 | app: App, | |
| 1182 | router: Router<Handler>, | |
| 1183 | limiter: RateLimiter, | |
| 1184 | request: Request, | |
| 1168 | 1185 | ): Promise<Response> { |
| 1169 | 1186 | const url = publicUrl(request, app.config.url); |
| 1170 | 1187 | if (url.pathname === "/healthz") return healthResponse(); |
| 1171 | const matched = router.match(request.method, url.pathname); | |
| 1188 | const routeMethod = request.method === "HEAD" ? "GET" : request.method; | |
| 1189 | const matched = router.match(routeMethod, url.pathname); | |
| 1172 | 1190 | if (!matched) { |
| 1173 | 1191 | // A path under no route at all is what a mistyped or stale studio |
| 1174 | 1192 | // URL lands on. A browser gets the forge's own page for it, same as |
test/web.test.ts +54
| @@ -645,6 +645,60 @@ describe("readiness", () => { | ||
| 645 | 645 | }); |
| 646 | 646 | }); |
| 647 | 647 | |
| 648 | describe("HEAD mirrors the GET it stands in for", () => { | |
| 649 | let dataDir: string; | |
| 650 | let app: App; | |
| 651 | let servers: Servers; | |
| 652 | let base: string; | |
| 653 | ||
| 654 | beforeAll(async () => { | |
| 655 | dataDir = await mkdtemp(join(tmpdir(), "forge-head-")); | |
| 656 | app = createApp({ dataDir, port: 0 }); | |
| 657 | servers = await serve(app); | |
| 658 | base = servers.url; | |
| 659 | }); | |
| 660 | ||
| 661 | afterAll(async () => { | |
| 662 | servers.stop(); | |
| 663 | app.close(); | |
| 664 | await rm(dataDir, { recursive: true, force: true }); | |
| 665 | }); | |
| 666 | ||
| 667 | test("HEAD / is 200, empty, with the same content-type and security floor as GET /", async () => { | |
| 668 | const get = await fetch(`${base}/`); | |
| 669 | const getBody = await get.text(); | |
| 670 | expect(getBody.length).toBeGreaterThan(0); | |
| 671 | ||
| 672 | const head = await fetch(`${base}/`, { method: "HEAD" }); | |
| 673 | expect(head.status).toBe(get.status); | |
| 674 | expect(await head.text()).toBe(""); | |
| 675 | expect(head.headers.get("content-type")).toBe(get.headers.get("content-type")); | |
| 676 | expect(head.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 677 | expect(head.headers.get("referrer-policy")).toBeTruthy(); | |
| 678 | expect(head.headers.get("content-security-policy")).toContain("nonce-"); | |
| 679 | }); | |
| 680 | ||
| 681 | test("HEAD /healthz is 200 with an empty body", async () => { | |
| 682 | const head = await fetch(`${base}/healthz`, { method: "HEAD" }); | |
| 683 | expect(head.status).toBe(200); | |
| 684 | expect(await head.text()).toBe(""); | |
| 685 | }); | |
| 686 | ||
| 687 | test("HEAD of a path with no route at all is 404", async () => { | |
| 688 | const head = await fetch(`${base}/no/such/route`, { method: "HEAD" }); | |
| 689 | expect(head.status).toBe(404); | |
| 690 | expect(await head.text()).toBe(""); | |
| 691 | }); | |
| 692 | ||
| 693 | test("HEAD of a path that only has a POST route is 404, and never runs the handler", async () => { | |
| 694 | const head = await fetch(`${base}/theme`, { method: "HEAD" }); | |
| 695 | expect(head.status).toBe(404); | |
| 696 | expect(await head.text()).toBe(""); | |
| 697 | // The POST handler would have set a cookie; a HEAD must never reach it. | |
| 698 | expect(head.headers.get("set-cookie")).toBeNull(); | |
| 699 | }); | |
| 700 | }); | |
| 701 | ||
| 648 | 702 | const FLOOR_POLICY = |
| 649 | 703 | "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"; |
| 650 | 704 |
new above
offline