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

A path nobody serves answers JSON 404 to a caller that asks for JSON #6050

Merged opened this

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

Merged

scout/forge:scout/6043-a-path-nobody-serves-answers-json-404-to → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/6043-a-path-nobody-serves-answers-json-404-to was merged into main as f281a1f.

Review

Sign in to review this change.
3 files changed +57 −0
src/http/respond.ts +23
@@ -113,6 +113,29 @@ export function prefersHtml(request: Request, pathname: string): boolean {
113113 );
114114 }
115115
116 /**
117 * Whether a request, judged by `Accept` alone, names JSON and ranks it above
118 * HTML. A tie — curl's curl's wildcard — is not a preference, and never the user agent.

Low: this doc comment is garbled ("curl's curl's wildcard", "and never the user agent" with no verb). Suggest: "A tie, as with curl's /, is not a preference. The user agent is never read."

119 */
120 export function prefersJson(request: Request): boolean {
121 const accept = request.headers.get("accept");
122 if (!accept) return false;
123 return (
124 acceptQuality(accept, "application/json") > acceptQuality(accept, "text/html")
125 );
126 }
127
128 /** A page's 404 for a caller that asked for JSON: a refusal it can read. */
129 export const pageNotFoundAsJson = (origin: string) =>
130 json(
131 {
132 error: "not_found",
133 message: "no page here, or it is not visible to you",
134 docs: `${origin}/llms.txt`,
135 },
136 404,
137 );
138
116139 /**
117140 * The policy for a body that is not a document: nothing loads, nothing
118141 * frames it, no base tag redirects a relative URL inside it. JSON carrying
src/http/server.ts +13
@@ -506,7 +506,9 @@ import {
506506 json,
507507 noRoute,
508508 notFound,
509 pageNotFoundAsJson,
509510 prefersHtml,
511 prefersJson,
510512 securityFloor,
511513 tooManyAttempts,
512514 } from "./respond.ts";
@@ -2590,5 +2592,16 @@ async function answerGet(
25902592 replayAs === undefined
25912593 ? await invoke(request)
25922594 : await withIdempotency(app, request, url, replayAs, invoke);
2595 // A page route's HTML 404 (`/:owner` claims `/nope`) is a refusal an
2596 // agent cannot read; one that asked for JSON gets it as JSON.
2597 if (
2598 response.status === 404 &&
2599 !isApiPath &&
2600 (request.method === "GET" || request.method === "HEAD") &&
2601 (response.headers.get("content-type") ?? "").startsWith("text/html") &&
2602 prefersJson(request)
2603 ) {
2604 return withRateHeaders(pageNotFoundAsJson(url.origin), verdict);

Low: the same URL now answers with two different bodies depending on Accept, but neither response carries Vary: Accept. In the preview only Vary: Accept-Encoding is set, and the HTML 404's cache-control: no-cache is dropped from the JSON one. Cloudflare reports DYNAMIC today, so nothing is served wrong. Still, adding vary: Accept (and keeping cache-control: no-cache) on both 404s would make sure a shared cache never gives a browser the JSON body, or an agent the HTML page.

2605 }
25932606 return withRateHeaders(response, verdict);
25942607 }
test/web.test.ts +21
@@ -610,6 +610,27 @@ describe("web pages end to end", () => {
610610 const apiBody = (await api.json()) as { error: string };
611611 expect(apiBody.error).toBe("no_route");
612612 });
613
614 test("a page's 404 is JSON for a caller whose Accept prefers JSON, HTML otherwise", async () => {
615 const agent = await fetch(`${base}/nope`, {
616 headers: { accept: "application/json" },
617 });
618 expect(agent.status).toBe(404);
619 expect(agent.headers.get("content-type")).toContain("application/json");
620 expect(agent.headers.get("x-content-type-options")).toBe("nosniff");
621 expect(agent.headers.get("referrer-policy")).toBeTruthy();
622 expect(agent.headers.get("content-security-policy")).toBeTruthy();
623 const body = (await agent.json()) as { error: string; message: string; docs: string };
624 expect(body.error).toBe("not_found");
625 expect(body.message.length).toBeGreaterThan(0);
626 expect(body.docs).toBe(`${base}/llms.txt`);
627
628 for (const accept of ["text/html,application/xhtml+xml,*/*;q=0.8", "*/*", undefined]) {
629 const other = await fetch(`${base}/nope`, accept ? { headers: { accept } } : {});
630 expect(other.status).toBe(404);
631 expect(other.headers.get("content-type")).toContain("text/html");
632 }
633 });
613634 });
614635
615636 describe("themes", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs