Public: anyone can read this; the crew can change it.
Every error response carries the security floor: a test walks 400, 401, 403, 404, 405, 413 and 500 #10954
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10949-every-error-response-carries-the-securit → main 8 commits fork 2 threads · 2 unresolved
7 files
changed +463
−45
src/http/respond.ts +29 −6
| @@ -29,10 +29,15 @@ export function json( | ||
| 29 | 29 | data = { ...data, retryAfter: wait }; |
| 30 | 30 | } |
| 31 | 31 | data = withHint(data, status); |
| 32 | return new Response(JSON.stringify(data), { | |
| 33 | status, | |
| 34 | headers: { "content-type": "application/json", ...headers }, | |
| 35 | }); | |
| 32 | // The floor is laid here as well as at the server's one door (#10949): a | |
| 33 | // JSON answer carried by a path that skips the door — the internal | |
| 34 | // listener, a test calling a handler — still leaves inert. | |
| 35 | return securityFloor( | |
| 36 | new Response(JSON.stringify(data), { | |
| 37 | status, | |
| 38 | headers: { "content-type": "application/json", ...headers }, | |
| 39 | }), | |
| 40 | ); | |
| 36 | 41 | } |
| 37 | 42 | |
| 38 | 43 | /** |
| @@ -339,13 +344,31 @@ function asObject(bytes: Uint8Array): Record<string, unknown> { | ||
| 339 | 344 | * A JSON object body, at most \`BODY_MAX\` bytes for a signed-in caller and |
| 340 | 345 | * \`STRANGER_MAX\` for anyone else (\`http/limits.ts\`) — a route reads its |
| 341 | 346 | * body before it asks who is calling, so the cap cannot wait for that. Past |
| 342 | * the cap, or malformed, \`{}\`; an over-limit body closes its connection. | |
| 347 | * the cap it throws \`BodyTooLarge\` (answered 413) and the connection closes; | |
| 348 | * malformed, \`{}\`. | |
| 343 | 349 | */ |
| 344 | 350 | export async function body(request: Request): Promise<Record<string, unknown>> { |
| 345 | 351 | const bytes = await readBounded(request, bodyCapFor(request)); |
| 346 | return bytes === "too_large" ? {} : asObject(bytes); | |
| 352 | if (bytes === "too_large") throw new BodyTooLarge(bodyCapFor(request)); | |
| 353 | return asObject(bytes); | |
| 347 | 354 | } |
| 348 | 355 | |
| 356 | /** | |
| 357 | * A body past its cap, thrown by \`body\` so the handler stops before it acts | |
| 358 | * on nothing: read as \`{}\`, an oversize issue answered 422 for a missing | |
| 359 | * title, and a route with defaults would have acted on none (#10949). The | |
| 360 | * server's door answers it 413 (\`http/server.ts\`, \`failure\`). | |
| 361 | */ | |
| 362 | export class BodyTooLarge extends Error { | |
| 363 | constructor(readonly max: number) { | |
| 364 | super(`body over ${max} bytes`); | |
| 365 | } | |
| 366 | } | |
| 367 | ||
| 368 | /** The 413 a \`BodyTooLarge\` is answered with. */ | |
| 369 | export const bodyTooLarge = (error: BodyTooLarge): Response => | |
| 370 | apiError("too_large", 413, `at most ${error.max} bytes`); | |
| 371 | ||
| 349 | 372 | /** |
| 350 | 373 | * A JSON object body kept to at most \`max\` bytes, for a route a stranger |
| 351 | 374 | * may call: the declared length is a claim and a chunked body declares |
src/http/server.ts +25 −18
| @@ -542,6 +542,8 @@ import { | ||
| 542 | 542 | prefersHtml, |
| 543 | 543 | prefersJson, |
| 544 | 544 | securityFloor, |
| 545 | BodyTooLarge, | |
| 546 | bodyTooLarge, | |
| 545 | 547 | privateWhenSignedIn, |
| 546 | 548 | tooManyAttempts, |
| 547 | 549 | tryAgain, |
| @@ -2178,6 +2180,7 @@ export interface Servers { | ||
| 2178 | 2180 | * when a database trigger refused, else a 500 that says nothing of the fault. |
| 2179 | 2181 | */ |
| 2180 | 2182 | function failure(app: App, error: unknown): Response { |
| 2183 | if (error instanceof BodyTooLarge) return securityFloor(bodyTooLarge(error)); | |
| 2181 | 2184 | const refused = refusedByDatabase(error); |
| 2182 | 2185 | if (refused !== undefined) { |
| 2183 | 2186 | const shown = shownRefusal(refused); |
| @@ -2262,24 +2265,8 @@ function serveInternal(app: App): InternalListener { | ||
| 2262 | 2265 | const onPort = portInsteadOfSocket(); |
| 2263 | 2266 | const internal = Bun.serve({ |
| 2264 | 2267 | ...(onPort ? { hostname: "127.0.0.1", port: 0 } : { unix: app.socketPath }), |
| 2265 | fetch(request) { | |
| 2266 | // The socket's file mode used to be the only door check; a loopback | |
| 2267 | // port has none, so every internal request proves the boot secret. | |
| 2268 | if (!hookCallerAllowed(app, request)) { | |
| 2269 | const path = new URL(request.url).pathname; | |
| 2270 | if (noteRefusedInternal(request.method, path, Date.now(), app.signals.refused)) | |
| 2271 | app.log.warn("internal call refused: no boot secret", { | |
| 2272 | method: request.method, | |
| 2273 | path: path.slice(0, 120), | |
| 2274 | }); | |
| 2275 | return new Response("forbidden", { status: 403 }); | |
| 2276 | } | |
| 2277 | const url = new URL(request.url); | |
| 2278 | if (request.method === "POST" && url.pathname === "/internal/hook") { | |
| 2279 | return handleHook(app, request); | |
| 2280 | } | |
| 2281 | return new Response("not found", { status: 404 }); | |
| 2282 | }, | |
| 2268 | // Only hooks call here, but the floor holds on every listener (#10949). | |
| 2269 | fetch: async (request) => securityFloor(await answerInternal(app, request)), | |
| 2283 | 2270 | }); |
| 2284 | 2271 | if (onPort) app.hookAddress.current = tcpAddress(internal.port ?? 0); |
| 2285 | 2272 | return { |
| @@ -2291,6 +2278,26 @@ function serveInternal(app: App): InternalListener { | ||
| 2291 | 2278 | }; |
| 2292 | 2279 | } |
| 2293 | 2280 | |
| 2281 | /** The internal listener's answer: a hook, with the boot secret, or nothing. */ | |
| 2282 | async function answerInternal(app: App, request: Request): Promise<Response> { | |
| 2283 | // The socket's file mode used to be the only door check; a loopback | |
| 2284 | // port has none, so every internal request proves the boot secret. | |
| 2285 | if (!hookCallerAllowed(app, request)) { | |
| 2286 | const path = new URL(request.url).pathname; | |
| 2287 | if (noteRefusedInternal(request.method, path, Date.now(), app.signals.refused)) | |
| 2288 | app.log.warn("internal call refused: no boot secret", { | |
| 2289 | method: request.method, | |
| 2290 | path: path.slice(0, 120), | |
| 2291 | }); | |
| 2292 | return new Response("forbidden", { status: 403 }); | |
| 2293 | } | |
| 2294 | const url = new URL(request.url); | |
| 2295 | if (request.method === "POST" && url.pathname === "/internal/hook") { | |
| 2296 | return handleHook(app, request); | |
| 2297 | } | |
| 2298 | return new Response("not found", { status: 404 }); | |
| 2299 | } | |
| 2300 | ||
| 2294 | 2301 | /** |
| 2295 | 2302 | * One forge's HTTP routes without a listener: what the parent's Host |
| 2296 | 2303 | * dispatch uses to answer for a hosted child in-process. |
src/spawn/beat-door.ts +18 −14
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { App } from "../app.ts"; |
| 11 | import { securityFloor } from "../http/respond.ts"; | |
| 11 | 12 | import { readCapped } from "./bounded_read.ts"; |
| 12 | 13 | import { receiveBeat, SIGNATURE_HEADER } from "./heartbeat.ts"; |
| 13 | 14 | import { creditAnswer, lendModel, MODEL_BODY_MAX, type ModelDeps } from "./model_door.ts"; |
| @@ -31,20 +32,23 @@ export function openBeatDoor(app: App, deps: { readonly model?: ModelDeps } = {} | ||
| 31 | 32 | hostname: "127.0.0.1", |
| 32 | 33 | // Bun holds one limit per listener, and the model route needs the larger; each route counts the bytes it reads (`readCapped`). |
| 33 | 34 | maxRequestBodySize: MODEL_BODY_MAX, |
| 34 | async fetch(req) { | |
| 35 | const refused = new Response("no such door", { status: 404 }); | |
| 36 | const path = new URL(req.url).pathname; | |
| 37 | if (req.method === "POST" && path === "/v1/messages") return lendModel(app, req, deps.model); | |
| 38 | if (req.method === "GET" && path === "/api/v1/credit") return creditAnswer(app, req); | |
| 39 | if (req.method !== "POST" || path !== "/api/v1/heartbeat") return refused; | |
| 40 | // A beat is signed and small: no signature, no read; then at most MAX_BODY bytes, counted as they arrive. | |
| 41 | const signature = req.headers.get(SIGNATURE_HEADER); | |
| 42 | if (signature === null) return refused; | |
| 43 | const bytes = await readCapped(req, MAX_BODY); | |
| 44 | if (bytes === undefined) return refused; | |
| 45 | const kept = await receiveBeat(app, new TextDecoder().decode(bytes), signature); | |
| 46 | return kept ? Response.json({ ok: true }) : refused; | |
| 47 | }, | |
| 35 | // The security floor on every answer, like the forge's own door (#10949). | |
| 36 | fetch: async (req) => securityFloor(await answerBeat(app, deps, req)), | |
| 48 | 37 | }); |
| 49 | 38 | return { port: server.port!, close: () => server.stop(true) }; |
| 50 | 39 | } |
| 40 | ||
| 41 | async function answerBeat(app: App, deps: { readonly model?: ModelDeps }, req: Request): Promise<Response> { | |
| 42 | const refused = new Response("no such door", { status: 404 }); | |
| 43 | const path = new URL(req.url).pathname; | |
| 44 | if (req.method === "POST" && path === "/v1/messages") return lendModel(app, req, deps.model); | |
| 45 | if (req.method === "GET" && path === "/api/v1/credit") return creditAnswer(app, req); | |
| 46 | if (req.method !== "POST" || path !== "/api/v1/heartbeat") return refused; | |
| 47 | // A beat is signed and small: no signature, no read; then at most MAX_BODY bytes, counted as they arrive. | |
| 48 | const signature = req.headers.get(SIGNATURE_HEADER); | |
| 49 | if (signature === null) return refused; | |
| 50 | const bytes = await readCapped(req, MAX_BODY); | |
| 51 | if (bytes === undefined) return refused; | |
| 52 | const kept = await receiveBeat(app, new TextDecoder().decode(bytes), signature); | |
| 53 | return kept ? Response.json({ ok: true }) : refused; | |
| 54 | } |
test/mcp-system-stalls.test.ts +7 −4
| @@ -9,6 +9,7 @@ import { createUser } from "../src/core/users.ts"; | ||
| 9 | 9 | import { serve, type Servers } from "../src/http/server.ts"; |
| 10 | 10 | import { during } from "../src/perf/jobs.ts"; |
| 11 | 11 | import { loopWatch } from "../src/perf/live.ts"; |
| 12 | import { LIMIT_MS } from "../src/perf/loop.ts"; | |
| 12 | 13 | import { quietLogs } from "./helpers/globals.ts"; |
| 13 | 14 | |
| 14 | 15 | quietLogs(); |
| @@ -34,10 +35,12 @@ afterAll(async () => { | ||
| 34 | 35 | }); |
| 35 | 36 | |
| 36 | 37 | test("a stall under a named job is read through system, at most 10, most ms first", async () => { |
| 37 | // A stall is blamed on a request first: let any request an earlier file in this process ran fall out of the stall's window. | |
| 38 | await Bun.sleep(400); | |
| 38 | // A stall is blamed on any request that finished inside it, before a job: | |
| 39 | // wait out the requests an earlier file in this process ended, then stall | |
| 40 | // just past the limit so the window holds the job alone. | |
| 41 | await Bun.sleep(LIMIT_MS + 50); | |
| 39 | 42 | during("test-job-10907", () => 1); |
| 40 | loopWatch.stalled(300, performance.now()); | |
| 43 | loopWatch.stalled(LIMIT_MS + 10, performance.now()); | |
| 41 | 44 | const res = await fetch(`${servers.url}/mcp`, { |
| 42 | 45 | method: "POST", |
| 43 | 46 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, |
| @@ -46,7 +49,7 @@ test("a stall under a named job is read through system, at most 10, most ms firs | ||
| 46 | 49 | const body = (await res.json()) as any; |
| 47 | 50 | const out = JSON.parse(body.result.content[0].text); |
| 48 | 51 | expect(out.stalls.length).toBeLessThanOrEqual(10); |
| 49 | expect(out.stalls).toContainEqual({ cause: "job:test-job-10907", ms: 300, count: 1 }); | |
| 52 | expect(out.stalls).toContainEqual({ cause: "job:test-job-10907", ms: LIMIT_MS + 10, count: 1 }); | |
| 50 | 53 | const ms = out.stalls.map((s: any) => s.ms); |
| 51 | 54 | expect(ms).toEqual([...ms].sort((a, b) => b - a)); |
| 52 | 55 | }); |
test/security-floor-doors.test.ts added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | /** | |
| 2 | * Every response leaves through a listener, so the floor is proven at the | |
| 3 | * listeners (#10949). A `new Response(` anywhere in src/ — html, text, a | |
| 4 | * redirect, a stream — reaches a client only as the answer of some | |
| 5 | * `Bun.serve` fetch; this test lists every one of those in src/ with why | |
| 6 | * what it returns carries the security floor, and fails on a listener it | |
| 7 | * has not been told about. Then it walks the public door's own returns, | |
| 8 | * and asks the running forge for answers that are not JSON. | |
| 9 | */ | |
| 10 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 11 | import { readdirSync, readFileSync } from "node:fs"; | |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join, relative } from "node:path"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { securityFloor } from "../src/http/respond.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { openBeatDoor } from "../src/spawn/beat-door.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | const ROOT = join(import.meta.dir, ".."); | |
| 24 | const FLOOR = ["content-security-policy", "x-content-type-options", "referrer-policy"] as const; | |
| 25 | ||
| 26 | /** file → how many `Bun.serve(` it holds, and why each floors or is not a door. */ | |
| 27 | const LISTENERS: Record<string, { count: number; why: string }> = { | |
| 28 | "src/http/server.ts": { | |
| 29 | count: 2, | |
| 30 | why: "the public door (`handle`, walked below) and the internal hook listener, whose fetch is `securityFloor(await answerInternal(...))`", | |
| 31 | }, | |
| 32 | "src/spawn/beat-door.ts": { count: 1, why: "fetch is `securityFloor(await answerBeat(...))`, asked below" }, | |
| 33 | "src/kernel/port.ts": { count: 1, why: "a port probe, stopped before any request can reach it" }, | |
| 34 | "src/apps/process.ts": { count: 1, why: "a port probe, stopped before any request can reach it" }, | |
| 35 | "src/apps/scaffold.ts": { count: 1, why: "text of another app's server, written into its repository; it lays its own floor" }, | |
| 36 | "src/actions/spawn.ts": { count: 1, why: "text of a fresh app's index.ts, written into its repository" }, | |
| 37 | }; | |
| 38 | ||
| 39 | const sources = (dir: string): string[] => | |
| 40 | readdirSync(dir, { withFileTypes: true }).flatMap((e) => | |
| 41 | e.isDirectory() ? sources(join(dir, e.name)) : e.name.endsWith(".ts") ? [join(dir, e.name)] : [], | |
| 42 | ); | |
| 43 | ||
| 44 | test("every listener in src/ is named, with why its answers carry the floor", () => { | |
| 45 | const found: Record<string, number> = {}; | |
| 46 | for (const file of sources(join(ROOT, "src"))) { | |
| 47 | const n = readFileSync(file, "utf8").split("Bun.serve(").length - 1; | |
| 48 | if (n > 0) found[relative(ROOT, file)] = n; | |
| 49 | } | |
| 50 | expect(found).toEqual(Object.fromEntries(Object.entries(LISTENERS).map(([f, l]) => [f, l.count]))); | |
| 51 | }); | |
| 52 | ||
| 53 | test("the internal listener and the beat door hand their fetch to securityFloor", () => { | |
| 54 | const server = readFileSync(join(ROOT, "src/http/server.ts"), "utf8"); | |
| 55 | expect(server).toContain("fetch: async (request) => securityFloor(await answerInternal(app, request)),"); | |
| 56 | const beat = readFileSync(join(ROOT, "src/spawn/beat-door.ts"), "utf8"); | |
| 57 | expect(beat).toContain("fetch: async (req) => securityFloor(await answerBeat(app, deps, req)),"); | |
| 58 | }); | |
| 59 | ||
| 60 | /** | |
| 61 | * What the public door may return: an answer it floored itself, one that | |
| 62 | * came through `answered` (which floors after compressing), a cell's or an | |
| 63 | * app's own (the app's headers are the app's business, `apps/proxy.ts`), or | |
| 64 | * nothing after an upgrade. | |
| 65 | */ | |
| 66 | const DOOR_RETURNS = [ | |
| 67 | /^securityFloor\(/, | |
| 68 | /^response;$/, | |
| 69 | /^withHeader\(response, "connection", "close"\);$/, | |
| 70 | /^proxyApp\(/, | |
| 71 | /^fromCell\(/, | |
| 72 | /^undefined;$/, | |
| 73 | /^socket === undefined \? undefined : securityFloor\(socket\);$/, | |
| 74 | /^first;$/, | |
| 75 | /^done;$/, | |
| 76 | ]; | |
| 77 | ||
| 78 | test("each return of the public door and of `answered` is floored", () => { | |
| 79 | const server = readFileSync(join(ROOT, "src/http/server.ts"), "utf8"); | |
| 80 | const from = server.indexOf(" const answered = async ("); | |
| 81 | const to = server.indexOf(" const web = Bun.serve("); | |
| 82 | expect(from).toBeGreaterThan(0); | |
| 83 | expect(to).toBeGreaterThan(from); | |
| 84 | const region = server.slice(from, to); | |
| 85 | const code = region | |
| 86 | .split("\n") | |
| 87 | .filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)) | |
| 88 | .join("\n"); | |
| 89 | const returns = [...code.matchAll(/\breturn\s+([^\n]+)/g)].map((m) => m[1]!.trim()); | |
| 90 | expect(returns.length).toBeGreaterThan(10); | |
| 91 | expect(returns.filter((r) => !DOOR_RETURNS.some((p) => p.test(r)))).toEqual([]); | |
| 92 | // `response` is only ever what `answered` gave back. | |
| 93 | for (const m of region.matchAll(/\bresponse\s*=\s*([^\n;]+)/g)) expect(m[1]!.trim()).toMatch(/^await answered\(/); | |
| 94 | // `answered` floors what the work returns, and its failure path floors too. | |
| 95 | expect(region).toContain("const done = await compress(request, response).then(securityFloor)"); | |
| 96 | expect(server).toMatch(/function failure\([^]*?\n}\n/); | |
| 97 | const failure = server.match(/function failure\([^]*?\n}\n/)![0]; | |
| 98 | for (const r of failure.matchAll(/\breturn\s+([^\n]+)/g)) expect(r[1]!.trim()).toMatch(/^securityFloor\(/); | |
| 99 | }); | |
| 100 | ||
| 101 | let dataDir = ""; | |
| 102 | let app: App, servers: Servers; | |
| 103 | ||
| 104 | beforeAll(async () => { | |
| 105 | dataDir = await mkdtemp(join(tmpdir(), "forge-floor-doors-")); | |
| 106 | app = createApp({ dataDir, port: 0 }); | |
| 107 | servers = await serve(app); | |
| 108 | }, 30_000); | |
| 109 | ||
| 110 | afterAll(async () => { | |
| 111 | servers?.stop(); | |
| 112 | app?.close(); | |
| 113 | await rm(dataDir, { recursive: true, force: true }); | |
| 114 | }); | |
| 115 | ||
| 116 | const lacks = (res: Response): string[] => FLOOR.filter((h) => !res.headers.get(h)); | |
| 117 | ||
| 118 | test("an html page, a redirect and plain text through the door carry the floor", async () => { | |
| 119 | const ask = (path: string, init: RequestInit = {}) => | |
| 120 | fetch(`${servers.url}${path}`, { redirect: "manual", ...init, headers: { accept: "text/html", ...init.headers } }); | |
| 121 | const page = await ask("/"); | |
| 122 | expect(page.headers.get("content-type")).toStartWith("text/html"); | |
| 123 | expect(lacks(page)).toEqual([]); | |
| 124 | // An html page writes its own policy; the floor keeps it. | |
| 125 | expect(page.headers.get("content-security-policy")).toContain("style-src"); | |
| 126 | ||
| 127 | const redirect = await ask("/new"); | |
| 128 | expect(redirect.status).toBeGreaterThanOrEqual(300); | |
| 129 | expect(redirect.status).toBeLessThan(400); | |
| 130 | expect(redirect.headers.get("location")).toBeTruthy(); | |
| 131 | expect(lacks(redirect)).toEqual([]); | |
| 132 | ||
| 133 | const text = await ask("/robots.txt", { headers: { accept: "text/plain" } }); | |
| 134 | expect(text.status).toBe(200); | |
| 135 | expect(text.headers.get("content-type")).toStartWith("text/plain"); | |
| 136 | expect(lacks(text)).toEqual([]); | |
| 137 | }); | |
| 138 | ||
| 139 | test("the internal listener floors even the refusal of a caller with no boot secret", async () => { | |
| 140 | const at = app.hookAddress.current; | |
| 141 | const res = at.startsWith("tcp:") | |
| 142 | ? await fetch(`http://${at.slice("tcp:".length)}/internal/hook`, { method: "POST" }) | |
| 143 | : await fetch("http://localhost/internal/hook", { method: "POST", unix: at }); | |
| 144 | expect(res.status).toBe(403); | |
| 145 | expect(lacks(res)).toEqual([]); | |
| 146 | }); | |
| 147 | ||
| 148 | test("the beat door floors its refusals", async () => { | |
| 149 | const door = openBeatDoor(app); | |
| 150 | try { | |
| 151 | const res = await fetch(`http://127.0.0.1:${door.port}/nowhere`); | |
| 152 | expect(res.status).toBe(404); | |
| 153 | expect(lacks(res)).toEqual([]); | |
| 154 | } finally { | |
| 155 | door.close(); | |
| 156 | } | |
| 157 | }); | |
| 158 | ||
| 159 | test("securityFloor over an html response or a redirect lays the floor and keeps a page's own policy", () => { | |
| 160 | const html = securityFloor( | |
| 161 | new Response("<!doctype html>", { headers: { "content-type": "text/html", "content-security-policy": "default-src 'self'" } }), | |
| 162 | ); | |
| 163 | expect(lacks(html)).toEqual([]); | |
| 164 | expect(html.headers.get("content-security-policy")).toBe("default-src 'self'"); | |
| 165 | const moved = securityFloor(new Response(null, { status: 303, headers: { location: "/login" } })); | |
| 166 | expect(lacks(moved)).toEqual([]); | |
| 167 | expect(moved.headers.get("content-security-policy")).toContain("default-src 'none'"); | |
| 168 | }); |
test/security-floor-errors.test.ts added +208
| @@ -0,0 +1,208 @@ | ||
| 1 | /** | |
| 2 | * The security floor on the error paths nobody looks at (#10949): each of | |
| 3 | * 400, 401, 403, 404, 405, 413 and 500, provoked once on a page route and | |
| 4 | * once on an API route, must leave with Content-Security-Policy, | |
| 5 | * X-Content-Type-Options and Referrer-Policy. A 500 must also say nothing of | |
| 6 | * the fault: no stack, no path, no secret. | |
| 7 | * | |
| 8 | * `security-floor-every-route.test.ts` walks the kinds of answer; this one | |
| 9 | * walks the error statuses, page and API side by side, so a status that only | |
| 10 | * one side covers cannot hide a responder that skips the floor on the other. | |
| 11 | */ | |
| 12 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 13 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 14 | import { tmpdir } from "node:os"; | |
| 15 | import { join } from "node:path"; | |
| 16 | import { createApp, type App } from "../src/app.ts"; | |
| 17 | import { createRepo } from "../src/core/repos.ts"; | |
| 18 | import { createSession } from "../src/core/sessions.ts"; | |
| 19 | import { BODY_MAX } from "../src/http/limits.ts"; | |
| 20 | import { apiError, forbidden, json, methodNotAllowed, notFound, respondAccess, tryAgain, unauthorized } from "../src/http/respond.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | const FLOOR = ["content-security-policy", "x-content-type-options", "referrer-policy"] as const; | |
| 28 | const SECRET = "hunter2-the-database-password"; | |
| 29 | ||
| 30 | type Side = "page" | "api"; | |
| 31 | type Provoke = { status: number; side: Side; path: string; init?: RequestInit; signedIn?: boolean; failing?: boolean }; | |
| 32 | ||
| 33 | let dataDir = ""; | |
| 34 | let app: App, servers: Servers; | |
| 35 | let cookie = ""; | |
| 36 | ||
| 37 | beforeAll(async () => { | |
| 38 | dataDir = await mkdtemp(join(tmpdir(), "forge-floor-errors-")); | |
| 39 | app = createApp({ dataDir, port: 0 }); | |
| 40 | servers = await serve(app); | |
| 41 | const admin = await seedAdmin(app, { username: "ada", email: "[email protected]", password: "correct-horse-battery" }); | |
| 42 | if (!admin.ok) throw new Error(admin.error); | |
| 43 | const made = await createRepo(app.db, app.reposRoot, { ownerId: admin.value.id, name: "notes", visibility: "public" }); | |
| 44 | if (!made.ok) throw new Error(String(made.error)); | |
| 45 | cookie = `forge_session=${createSession(app.db, admin.value.id).token}`; | |
| 46 | }, 30_000); | |
| 47 | ||
| 48 | afterAll(async () => { | |
| 49 | servers?.stop(); | |
| 50 | app?.close(); | |
| 51 | await rm(dataDir, { recursive: true, force: true }); | |
| 52 | }); | |
| 53 | ||
| 54 | const HTML = { accept: "text/html" }; | |
| 55 | const own = () => ({ origin: servers.url, ...HTML }); | |
| 56 | const tooBig = () => new Uint8Array(BODY_MAX + 1024); | |
| 57 | ||
| 58 | const PROVOKED = (): Provoke[] => [ | |
| 59 | // 400 | |
| 60 | { status: 400, side: "page", path: "/talk/stream?after=not-a-turn", signedIn: true, init: { headers: HTML } }, | |
| 61 | { status: 400, side: "api", path: "/api/v1/explore?cursor=not-a-cursor" }, | |
| 62 | // 401 | |
| 63 | { status: 401, side: "page", path: "/talk/stream?after=x", init: { headers: HTML } }, | |
| 64 | { status: 401, side: "api", path: "/api/v1/user" }, | |
| 65 | // 403: the origin gate, for a form and for JSON | |
| 66 | { | |
| 67 | status: 403, | |
| 68 | side: "page", | |
| 69 | path: "/new", | |
| 70 | signedIn: true, | |
| 71 | init: { method: "POST", headers: { origin: "https://evil.example", ...HTML, "content-type": "application/x-www-form-urlencoded" }, body: "name=x" }, | |
| 72 | }, | |
| 73 | { | |
| 74 | status: 403, | |
| 75 | side: "api", | |
| 76 | path: "/api/v1/repos/ada/notes/issues", | |
| 77 | signedIn: true, | |
| 78 | init: { method: "POST", headers: { origin: "https://evil.example", "content-type": "application/json" }, body: "{}" }, | |
| 79 | }, | |
| 80 | // 404 | |
| 81 | { status: 404, side: "page", path: "/no/such/path/at/all", init: { headers: HTML } }, | |
| 82 | { status: 404, side: "api", path: "/api/v1/repos/ada/nothing-here" }, | |
| 83 | // 405 | |
| 84 | // A page path under the wrong method answers 404 (the page router keeps no | |
| 85 | // method table); the page that answers 405 is /mcp, to a browser. | |
| 86 | { status: 405, side: "page", path: "/mcp", init: { headers: HTML } }, | |
| 87 | { status: 404, side: "page", path: "/ada/notes", init: { method: "PUT", headers: HTML } }, | |
| 88 | { status: 405, side: "api", path: "/api/v1/repos/ada/notes", init: { method: "PUT" } }, | |
| 89 | // 413: a body past the cap, a form and JSON | |
| 90 | { | |
| 91 | status: 413, | |
| 92 | side: "page", | |
| 93 | path: "/new", | |
| 94 | signedIn: true, | |
| 95 | init: { method: "POST", headers: { ...own(), "content-type": "application/x-www-form-urlencoded" }, body: tooBig() }, | |
| 96 | }, | |
| 97 | { | |
| 98 | status: 413, | |
| 99 | side: "api", | |
The idempotency-key header hides the wrong status and unbounded read on issue create without it (422 instead of 413). Add a case without the key that expects 413, and fix the read cap. |
||
| 100 | path: "/api/v1/repos/ada/notes/issues", | |
| 101 | signedIn: true, | |
| 102 | init: { method: "POST", headers: { origin: servers.url, "content-type": "application/json" }, body: tooBig() }, | |
| 103 | }, | |
| 104 | // The same with an Idempotency-Key, which reads the body on its own path. | |
| 105 | { | |
| 106 | status: 413, | |
| 107 | side: "api", | |
| 108 | path: "/api/v1/repos/ada/notes/issues", | |
| 109 | signedIn: true, | |
| 110 | init: { method: "POST", headers: { origin: servers.url, "content-type": "application/json", "idempotency-key": "k1" }, body: tooBig() }, | |
| 111 | }, | |
| 112 | // 500: the database throws under a handler | |
| 113 | { status: 500, side: "page", path: "/", signedIn: true, failing: true, init: { headers: HTML } }, | |
| 114 | { status: 500, side: "api", path: "/api/v1/user", signedIn: true, failing: true }, | |
| 115 | ]; | |
| 116 | ||
| 117 | async function provoke(p: Provoke): Promise<{ res: Response; body: string }> { | |
| 118 | const headers = new Headers(p.init?.headers); | |
| 119 | headers.set("accept-encoding", "identity"); | |
| 120 | if (p.signedIn === true) headers.set("cookie", cookie); | |
| 121 | const query = app.db.query.bind(app.db); | |
| 122 | if (p.failing === true) | |
| 123 | app.db.query = (() => { | |
| 124 | throw new Error(`SQLITE_IOERR at ${dataDir}/forge.db with password=${SECRET}`); | |
| 125 | }) as typeof app.db.query; | |
| 126 | try { | |
| 127 | // A fresh connection each: an oversize body closes its connection, and a | |
| 128 | // client that reused it would read the runtime's own bare 400 instead. | |
| 129 | const res = await fetch(`${servers.url}${p.path}`, { | |
| 130 | ...p.init, | |
| 131 | headers, | |
| 132 | keepalive: false, | |
| 133 | redirect: "manual", | |
| 134 | signal: AbortSignal.timeout(10_000), | |
| 135 | }); | |
| 136 | return { res, body: await res.text() }; | |
| 137 | } finally { | |
| 138 | app.db.query = query; | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | test("each error status, on a page and on the API, carries the floor", async () => { | |
| 143 | const wrong: string[] = []; | |
| 144 | // One at a time: a 500 swaps the database's query for a throwing one. | |
| 145 | for (const p of PROVOKED()) { | |
| 146 | const { res } = await provoke(p); | |
| 147 | const name = `${p.status} ${p.side} ${p.init?.method ?? "GET"} ${p.path}`; | |
| 148 | if (res.status !== p.status) wrong.push(`${name} answered ${res.status}`); | |
| 149 | for (const h of FLOOR) if (!res.headers.get(h)) wrong.push(`${name} lacks ${h}`); | |
| 150 | } | |
| 151 | expect(wrong).toEqual([]); | |
| 152 | }, 30_000); | |
| 153 | ||
| 154 | test("every status in the issue is walked on both sides", () => { | |
| 155 | const walked = new Set(PROVOKED().map((p) => `${p.status}:${p.side}`)); | |
| 156 | for (const status of [400, 401, 403, 404, 405, 413, 500]) | |
| 157 | for (const side of ["page", "api"]) expect(walked.has(`${status}:${side}`)).toBe(true); | |
| 158 | }); | |
| 159 | ||
| 160 | test("a 500 names no stack, path or secret", async () => { | |
| 161 | for (const p of PROVOKED().filter((p) => p.status === 500)) { | |
| 162 | const { res, body } = await provoke(p); | |
| 163 | expect(res.status).toBe(500); | |
| 164 | expect(body).not.toContain(SECRET); | |
| 165 | expect(body).not.toContain(dataDir); | |
| 166 | expect(body).not.toContain("SQLITE_IOERR"); | |
| 167 | expect(body).not.toMatch(/\bat .+:\d+:\d+/); // a stack frame | |
| 168 | expect(body).not.toContain("/src/"); | |
| 169 | } | |
| 170 | }); | |
| 171 | ||
| 172 | test("every responder in respond.ts lays the floor itself, before the server's door", () => { | |
| 173 | const built = [ | |
| 174 | json({ ok: true }), | |
| 175 | apiError("bad_query", 400, "no"), | |
| 176 | unauthorized(), | |
| 177 | forbidden(), | |
| 178 | notFound(), | |
| 179 | methodNotAllowed("PUT", ["GET", "HEAD"]), | |
| 180 | apiError("too_large", 413, "no"), | |
| 181 | json({ error: "internal" }, 500), | |
| 182 | respondAccess("refused", 403), | |
| 183 | tryAgain("slow_down", 429, "wait", 5), | |
| 184 | ]; | |
| 185 | for (const res of built) { | |
| 186 | for (const h of FLOOR) expect(res.headers.get(h)).toBeTruthy(); | |
| 187 | expect(res.headers.get("content-security-policy")).toContain("default-src 'none'"); | |
| 188 | } | |
| 189 | }); | |
| 190 | ||
| 191 | test("a responder's own policy survives the floor", () => { | |
| 192 | const res = json({}, 200, { "content-security-policy": "sandbox" }); | |
| 193 | expect(res.headers.get("content-security-policy")).toBe("sandbox"); | |
| 194 | }); | |
| 195 | ||
| 196 | test("an oversize JSON body is refused 413 before the route acts on it", async () => { | |
| 197 | const { res, body } = await provoke({ | |
| 198 | status: 413, | |
| 199 | side: "api", | |
| 200 | path: "/api/v1/repos/ada/notes/issues", | |
| 201 | signedIn: true, | |
| 202 | init: { method: "POST", headers: { origin: servers.url, "content-type": "application/json" }, body: tooBig() }, | |
| 203 | }); | |
| 204 | expect(res.status).toBe(413); | |
| 205 | expect(JSON.parse(body).error).toBe("too_large"); | |
| 206 | const listed = await fetch(`${servers.url}/api/v1/repos/ada/notes/issues`, { headers: { cookie } }); | |
| 207 | expect(((await listed.json()) as { items: unknown[] }).items).toEqual([]); | |
| 208 | }); |
test/tui-transfer-repo.test.ts +8 −3
| @@ -69,12 +69,16 @@ function open(user: User, at: Repo, nested = false) { | ||
| 69 | 69 | /** Open the delete prompt from the palette once its row has drawn. */ |
| 70 | 70 | async function prompt(h: ReturnType<typeof open>): Promise<void> { |
| 71 | 71 | await h.feed(":", ..."Move this"); |
| 72 | for (let i = 0; i < 100 && !h.text().includes("old links answer"); i++) | |
| 73 | await new Promise<void>((r) => setTimeout(r, 10)), await h.feed(); | |
| 74 | if (!h.text().includes("old links answer")) throw new Error(`never came: still waiting while !h.text().includes("old links answer")`); | |
| 72 | await until(h, () => h.text().includes("old links answer"), "the palette row"); | |
| 75 | 73 | await h.feed("enter"); |
| 76 | 74 | } |
| 77 | 75 | |
| 76 | /** Wait, feeding nothing, until `done` holds: a loaded shard draws late, so give it 5 s. */ | |
| 77 | async function until(h: ReturnType<typeof open>, done: () => boolean, what: string): Promise<void> { | |
| 78 | for (let i = 0; i < 500 && !done(); i++) await new Promise<void>((r) => setTimeout(r, 10)), await h.feed(); | |
| 79 | if (!done()) throw new Error(`never came: ${what}`); | |
| 80 | } | |
| 81 | ||
| 78 | 82 | async function mkRepo(owner: string | undefined, name: string): Promise<Repo> { |
| 79 | 83 | const made = await createRepo(app, alice, { ...(owner ? { owner } : {}), name, visibility: "public" }); |
| 80 | 84 | if (!made.ok) throw new Error(String(made.error)); |
| @@ -118,6 +122,7 @@ describe("move a repository in the terminal", () => { | ||
| 118 | 122 | await prompt(h); |
| 119 | 123 | expect(h.text()).toContain("alice/wander"); |
| 120 | 124 | await h.feed(..."lab", "enter"); |
| 125 | await until(h, () => h.text().includes("moved alice/wander"), "the move's receipt"); | |
| 121 | 126 | expect(getRepoById(app.db, repo.id)?.ownerName).toBe("lab"); |
| 122 | 127 | expect(h.text()).toContain("moved alice/wander to lab/wander"); |
| 123 | 128 | h.stop(); |
Only JSON responses get the floor here. Other
new Response(sites across src/ do not. Route them through securityFloor or prove with a test that each passes the server door.