Public: anyone can read this; the crew can change it.

The API's own root answers 33 KB of HTML to a client that asked for JSON #314

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/279-the-api-s-own-root-answers-33-kb-of-html → main 1 commit fork

  • Merged. scout/279-the-api-s-own-root-answers-33-kb-of-html was merged into main as aee52cb.

Review

Sign in to review this change.
3 files changed +86 −14
src/http/respond.ts +1 −1
@@ -87,7 +87,7 @@ function acceptQuality(accept: string, type: string): number {
8787 * not a script or `curl` that never said otherwise.
8888 */
8989 export function prefersHtml(request: Request, pathname: string): boolean {
90 if (pathname.startsWith("/api/")) return false;
90 if (pathname === "/api" || pathname.startsWith("/api/")) return false;
9191 const accept = request.headers.get("accept");
9292 if (!accept) return false;
9393 return (
src/http/server.ts +38 −13
@@ -536,8 +536,9 @@ export function buildRouter(): Router<Handler> {
536536 ),
537537 );
538538
539 // The JSON API lives in api.ts; its routes all sit under /api/v1.
540 apiRoutes(router);
539 // The JSON API lives in api.ts, on its own router (`buildApiRouter`
540 // below) — never here, so a `/:owner`-shaped studio route can never
541 // reclaim a path under `/api/`.
541542
542543 // Issues (web). The literal /new is registered before /:number so it can
543544 // never be captured as a number.
@@ -746,6 +747,18 @@ export function buildRouter(): Router<Handler> {
746747 return router;
747748 }
748749
750 /**
751 * The JSON API's own router, matched before the studio's when a path starts
752 * with `/api` — so a miss under `/api/` can never fall through to a
753 * `/:owner`-shaped page route and answer with a repo's or a profile's 404
754 * page instead of the API's own.
755 */
756 export function buildApiRouter(): Router<Handler> {
757 const router = new Router<Handler>();
758 apiRoutes(router);
759 return router;
760 }
761
749762 async function withGitAccess(
750763 ctx: Ctx,
751764 service: GitService,
@@ -1120,6 +1133,7 @@ async function clearStaleSockets(app: App): Promise<void> {
11201133
11211134 export async function serve(app: App): Promise<Servers> {
11221135 const router = buildRouter();
1136 const apiRouter = buildApiRouter();
11231137 await clearStaleSockets(app);
11241138 // One limiter per listener: its state is a minute of timestamps, and two
11251139 // forges in one process (every test file) must not share a budget.
@@ -1147,7 +1161,9 @@ export async function serve(app: App): Promise<Servers> {
11471161 }
11481162 // Everything the forge answers for itself leaves through this one
11491163 // door, so a responder added next year cannot forget the floor.
1150 return securityFloor(await answer(app, router, limiter, request));
1164 return securityFloor(
1165 await answer(app, router, apiRouter, limiter, request),
1166 );
11511167 },
11521168 error(error) {
11531169 app.log.error("unhandled request error", { error: String(error) });
@@ -1193,10 +1209,11 @@ export async function serve(app: App): Promise<Servers> {
11931209 async function answer(
11941210 app: App,
11951211 router: Router<Handler>,
1212 apiRouter: Router<Handler>,
11961213 limiter: RateLimiter,
11971214 request: Request,
11981215 ): Promise<Response> {
1199 const response = await answerGet(app, router, limiter, request);
1216 const response = await answerGet(app, router, apiRouter, limiter, request);
12001217 return request.method === "HEAD"
12011218 ? new Response(null, { status: response.status, headers: response.headers })
12021219 : response;
@@ -1205,24 +1222,32 @@ async function answer(
12051222 async function answerGet(
12061223 app: App,
12071224 router: Router<Handler>,
1225 apiRouter: Router<Handler>,
12081226 limiter: RateLimiter,
12091227 request: Request,
12101228 ): Promise<Response> {
12111229 const url = publicUrl(request, app.config.url);
12121230 if (url.pathname === "/healthz") return healthResponse();
12131231 const routeMethod = request.method === "HEAD" ? "GET" : request.method;
1214 const matched = router.match(routeMethod, url.pathname);
1232 // The one place a path decides its own shape of miss: anything under
1233 // `/api` matches only against the API's own routes, so a studio route as
1234 // generic as `/:owner/:name` can never claim it — a route added there
1235 // next year still can't bring the HTML 404 back for an API caller.
1236 const isApiPath = url.pathname === "/api" || url.pathname.startsWith("/api/");
1237 const matched = isApiPath
1238 ? apiRouter.match(routeMethod, url.pathname)
1239 : router.match(routeMethod, url.pathname);
12151240 if (!matched) {
12161241 // A path under no route at all is what a mistyped or stale studio
12171242 // URL lands on. A browser gets the forge's own page for it, same as
1218 // any other 404; an API client — anything outside `/api/` that never
1219 // said it wanted HTML — keeps the `no_route` body, distinct from
1220 // `not_found` so it can tell a typo from a resource that is really
1221 // gone. The page is rendered from a viewer-less `Ctx`: resolving a
1222 // session or token here would spend a database read on a path that,
1223 // by construction, cannot belong to a signed-in-only route, at the
1224 // cost of the topbar reading "Sign in" even for someone who is.
1225 if (prefersHtml(request, url.pathname)) {
1243 // any other 404; an API client — anything under `/api/` — keeps the
1244 // `no_route` body, distinct from `not_found` so it can tell a typo
1245 // from a resource that is really gone. The page is rendered from a
1246 // viewer-less `Ctx`: resolving a session or token here would spend a
1247 // database read on a path that, by construction, cannot belong to a
1248 // signed-in-only route, at the cost of the topbar reading "Sign in"
1249 // even for someone who is.
1250 if (!isApiPath && prefersHtml(request, url.pathname)) {
12261251 const themeCookie = cookieValue(request, "forge_theme") ?? "";
12271252 const modeCookieValue = cookieValue(request, "forge_mode") ?? "";
12281253 const anonCtx: Ctx = {
test/api-errors.test.ts +47
@@ -74,4 +74,51 @@ describe("API errors are distinguishable and self-describing", () => {
7474 expect(noAuth.error).toBe("unauthorized");
7575 expect(noAuth.message).toBeTruthy();
7676 });
77
78 // The API's own base, and any other path under `/api/` that no route
79 // claims, used to fall through to the studio's `/:owner`-shaped catch-all
80 // page routes ("api" and "v1" read as an owner and a repo name) and answer
81 // with thirty-three kilobytes of the not-found *page* — wrong shape, wrong
82 // content-type, to a caller whose `accept` said `application/json`.
83 test("/api and /api/v1 are JSON 404s, not the studio's HTML page", async () => {
84 for (const path of ["/api", "/api/v1", "/api/v2/anything"]) {
85 const res = await fetch(`${base}${path}`, {
86 headers: { accept: "application/json" },
87 });
88 expect(res.status).toBe(404);
89 expect(res.headers.get("content-type")).toStartWith("application/json");
90 const body = (await res.json()) as { error: string };
91 expect(body.error).toBe("no_route");
92 }
93 });
94
95 // Content negotiation never rescues it either: a bare curl with no
96 // `accept` header at all must still get JSON under `/api/`.
97 test("/api/v1 is JSON even with no accept header", async () => {
98 const res = await fetch(`${base}/api/v1`);
99 expect(res.status).toBe(404);
100 expect(res.headers.get("content-type")).toStartWith("application/json");
101 });
102
103 // A page path outside `/api/` keeps the forge's own not-found page,
104 // unchanged, for a browser that asks for HTML.
105 test("a page path with accept: text/html still gets the HTML page", async () => {
106 const res = await fetch(`${base}/this-owner-does-not-exist`, {
107 headers: { accept: "text/html" },
108 });
109 expect(res.status).toBe(404);
110 expect(res.headers.get("content-type")).toStartWith("text/html");
111 });
112
113 // The security floor is not the API's own responder's business — it is
114 // applied once, above every response the forge writes — but a miss under
115 // `/api/` must not be the one path that skips it.
116 test("the security floor still covers a miss under /api/", async () => {
117 const res = await fetch(`${base}/api/v1`, {
118 headers: { accept: "application/json" },
119 });
120 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
121 expect(res.headers.get("referrer-policy")).toBe("same-origin");
122 expect(res.headers.get("content-security-policy")).toBeTruthy();
123 });
77124 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs