Public: anyone can read this; the crew can change it.
One test walks every route the server registers and fails on any response without the security floor #10397
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10386-one-test-walks-every-route-the-server-re → main 1 commit fork
1 file
changed +284
−0
test/security-sweep.test.ts added +284
| @@ -0,0 +1,284 @@ | ||
| 1 | /** | |
| 2 | * One sweep over every route the two routers register, every verb each one | |
| 3 | * takes, held to the security floor (`securityFloor`, src/http/respond.ts) | |
| 4 | * and to the same-origin rule for cookies (#10386). | |
| 5 | * | |
| 6 | * Three passes over one seeded server, each request sent once: | |
| 7 | * | |
| 8 | * 1. Anonymous, each registered (method, pattern), plus the probes and an | |
| 9 | * unknown path: the floor (Content-Security-Policy, X-Content-Type-Options, | |
| 10 | * Referrer-Policy) on whatever comes back — 2xx, 3xx, 4xx, 404 and | |
| 11 | * streamed answers alike. Its mutations are sent from another origin | |
| 12 | * with the seed's real username and password: none may hand out a | |
| 13 | * session (login CSRF). | |
| 14 | * 2. Each API pattern with a verb it does not register: 405, its `Allow` | |
| 15 | * naming what the path does take, and the floor; a studio path's wrong | |
| 16 | * verb is the not-found page, asked once. | |
| 17 | * 3. Every mutating route, cross-origin, with a live session cookie: 403 | |
| 18 | * before any handler (`originGate`), no Set-Cookie, and the session is | |
| 19 | * still good afterwards. | |
| 20 | * | |
| 21 | * Every Set-Cookie seen in any pass, and the one a real same-origin sign-in | |
| 22 | * sets, is HttpOnly unless named in `READABLE_COOKIES` with why. | |
| 23 | * | |
| 24 | * Parameters are filled from one seeded user and repository; anything else | |
| 25 | * gets a placeholder, so those routes answer their miss — which must carry | |
| 26 | * the floor as much as a hit. `test/security-floor-routes.test.ts` fills | |
| 27 | * deeper (issues, pulls, runs, apps) for GET, signed in and out. | |
| 28 | * | |
| 29 | * About 1 800 requests, 32 in flight against the one server: the three | |
| 30 | * passes take about 3 s on their own (#10386 asks under 5 s), most of it the | |
| 31 | * first render of each page. The timeouts below leave room for a loaded | |
| 32 | * shard rather than hold a clock to it; the work is bounded by the route | |
| 33 | * table, one request per (verb, route) and pass. | |
| 34 | * | |
| 35 | * Side effects stay in the scratch FORGE_APP_DATA: the bodies sent are empty | |
| 36 | * or the seed's own credentials, and the cross-origin cookie pass is refused | |
| 37 | * before any handler runs. | |
| 38 | */ | |
| 39 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 40 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 41 | import { tmpdir } from "node:os"; | |
| 42 | import { join } from "node:path"; | |
| 43 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 44 | import { createApp, type App } from "../src/app.ts"; | |
| 45 | import { createIssue } from "../src/core/issues.ts"; | |
| 46 | import { createRepo } from "../src/core/repos.ts"; | |
| 47 | import { createSession, sessionOf } from "../src/core/sessions.ts"; | |
| 48 | import { buildApiRouter, buildRouter, serve, type Servers } from "../src/http/server.ts"; | |
| 49 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 50 | import { quietLogs } from "./helpers/globals.ts"; | |
| 51 | ||
| 52 | quietLogs(); | |
| 53 | ||
| 54 | const USERNAME = "ada"; | |
| 55 | const PASSWORD = "correct-horse-battery"; | |
| 56 | const EVIL = "https://evil.example"; | |
| 57 | const FLOOR = ["content-security-policy", "x-content-type-options", "referrer-policy"] as const; | |
| 58 | const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]); | |
| 59 | ||
| 60 | /** | |
| 61 | * Responses allowed to leave without part of the floor → why. Kept empty | |
| 62 | * while every answer carries it: `/healthz`, `/readyz` and the hashed | |
| 63 | * assets all leave through the one door (`answered` in src/http/server.ts) | |
| 64 | * that applies it. A path added here must still be a real exception: the | |
| 65 | * test below fails when a named one carries the full floor after all. | |
| 66 | */ | |
| 67 | const FLOOR_EXCEPTIONS: Record<string, string> = {}; | |
| 68 | ||
| 69 | /** | |
| 70 | * API-router paths whose wrong verb is a plain 404, not a 405 → why. Each | |
| 71 | * still carries the floor; the test fails if one starts answering 405. | |
| 72 | */ | |
| 73 | const NO_405: Record<string, string> = { | |
| 74 | "/api/v1": "the API root is read-only and answers a write as it always did: no route (`answerGet`, src/http/server.ts)", | |
| 75 | "/api/v1/": "the API root with its slash, the same read-only root as /api/v1", | |
| 76 | "/metrics": "a scraper's path outside /api/, so its miss is the machine-path 404, not the API's 405", | |
| 77 | }; | |
| 78 | ||
| 79 | /** Cookies a page's own script must read, so not HttpOnly → why. */ | |
| 80 | const READABLE_COOKIES: Record<string, string> = {}; | |
| 81 | ||
| 82 | let dataDir = ""; | |
| 83 | let app: App; | |
| 84 | let servers: Servers; | |
| 85 | let session = ""; | |
| 86 | let token = ""; | |
| 87 | const fill: Record<string, string> = { owner: USERNAME, name: "notes", username: USERNAME, ref: "main", path: "a.txt" }; | |
| 88 | ||
| 89 | beforeAll(async () => { | |
| 90 | dataDir = await mkdtemp(join(tmpdir(), "forge-sweep-")); | |
| 91 | app = createApp({ dataDir, port: 0 }); | |
| 92 | servers = await serve(app); | |
| 93 | const admin = await seedAdmin(app, { username: USERNAME, email: "[email protected]", password: PASSWORD }); | |
| 94 | if (!admin.ok) throw new Error(String(admin.error)); | |
| 95 | const made = await createRepo(app.db, app.reposRoot, { ownerId: admin.value.id, name: "notes", visibility: "public" }); | |
| 96 | if (!made.ok) throw new Error(String(made.error)); | |
| 97 | const seeded = await commitToBranch(app, admin.value, made.value, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] }); | |
| 98 | if (!seeded.ok) throw new Error(String(seeded.error)); | |
| 99 | const issue = createIssue(app.db, { repoId: made.value.id, title: "an issue", body: "b", authorId: admin.value.id }); | |
| 100 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 101 | fill.number = String(issue.value.number); | |
| 102 | token = createSession(app.db, admin.value.id).token; | |
| 103 | session = `forge_session=${token}`; | |
| 104 | }, 30_000); | |
| 105 | ||
| 106 | afterAll(async () => { | |
| 107 | servers?.stop(); | |
| 108 | app?.close(); | |
| 109 | await rm(dataDir, { recursive: true, force: true }); | |
| 110 | }); | |
| 111 | ||
| 112 | interface Route { | |
| 113 | method: string; | |
| 114 | pattern: string; | |
| 115 | api: boolean; | |
| 116 | } | |
| 117 | ||
| 118 | const routes = (): Route[] => { | |
| 119 | const seen = new Set<string>(); | |
| 120 | const out: Route[] = []; | |
| 121 | for (const [router, api] of [[buildRouter(), false], [buildApiRouter(), true]] as const) | |
| 122 | for (const { method, pattern } of router.routes()) { | |
| 123 | const key = `${method} ${pattern}`; | |
| 124 | if (seen.has(key)) continue; | |
| 125 | seen.add(key); | |
| 126 | out.push({ method, pattern, api }); | |
| 127 | } | |
| 128 | return out; | |
| 129 | }; | |
| 130 | ||
| 131 | const pathFor = (pattern: string): string => | |
| 132 | pattern.replace(/[:*]([a-z_]+)/g, (_, key: string) => encodeURIComponent(fill[key] ?? "x")); | |
| 133 | ||
| 134 | interface Seen { | |
| 135 | label: string; | |
| 136 | status: number; | |
| 137 | type: string; | |
| 138 | missing: string[]; | |
| 139 | cookies: string[]; | |
| 140 | allow: string | null; | |
| 141 | } | |
| 142 | ||
| 143 | async function send(method: string, path: string, headers: Record<string, string>, body?: string): Promise<Seen> { | |
| 144 | const res = await fetch(`${servers.url}${path}`, { | |
| 145 | method, | |
| 146 | headers: { "accept-encoding": "identity", ...headers }, | |
| 147 | body, | |
| 148 | redirect: "manual", | |
| 149 | signal: AbortSignal.timeout(5_000), | |
| 150 | }); | |
| 151 | await res.body?.cancel().catch(() => {}); | |
| 152 | return { | |
| 153 | label: `${method} ${path} → ${res.status}`, | |
| 154 | status: res.status, | |
| 155 | type: res.headers.get("content-type") ?? "", | |
| 156 | missing: FLOOR.filter((h) => !res.headers.get(h)), | |
| 157 | cookies: res.headers.getSetCookie(), | |
| 158 | allow: res.headers.get("allow"), | |
| 159 | }; | |
| 160 | } | |
| 161 | ||
| 162 | /** Run every job, a few at a time: the server is one process, so more buys nothing. */ | |
| 163 | async function pool<T, R>(items: T[], work: (item: T) => Promise<R>, width = 32): Promise<R[]> { | |
| 164 | const out: R[] = new Array(items.length); | |
| 165 | let next = 0; | |
| 166 | await Promise.all( | |
| 167 | Array.from({ length: width }, async () => { | |
| 168 | while (next < items.length) { | |
| 169 | const i = next++; | |
| 170 | out[i] = await work(items[i]!); | |
| 171 | } | |
| 172 | }), | |
| 173 | ); | |
| 174 | return out; | |
| 175 | } | |
| 176 | ||
| 177 | const floorGaps = (seen: Seen[]): string[] => | |
| 178 | seen | |
| 179 | .filter((s) => s.missing.length > 0 && FLOOR_EXCEPTIONS[s.label.split(" ")[1]!] === undefined) | |
| 180 | .map((s) => `${s.label} lacks ${s.missing.join(", ")}`); | |
| 181 | ||
| 182 | const cookieName = (header: string): string => header.slice(0, header.indexOf("=")); | |
| 183 | const notHttpOnly = (seen: Seen[]): string[] => | |
| 184 | seen.flatMap((s) => | |
| 185 | s.cookies | |
| 186 | .filter((c) => !/;\s*httponly\s*(;|$)/i.test(c) && READABLE_COOKIES[cookieName(c)] === undefined) | |
| 187 | .map((c) => `${s.label} sets ${cookieName(c)} without HttpOnly`), | |
| 188 | ); | |
| 189 | ||
| 190 | const anonymous: Seen[] = []; | |
| 191 | const wrongMethod: Seen[] = []; | |
| 192 | ||
| 193 | test("every registered route and verb, asked anonymously, answers with the floor and hands out no session", async () => { | |
| 194 | // A mutation is sent from another origin with the seed's real username | |
| 195 | // and password, as a page elsewhere could: no route may answer it with a | |
| 196 | // session (login CSRF), whatever it does with the rest. | |
| 197 | const form = new URLSearchParams({ username: USERNAME, password: PASSWORD }).toString(); | |
| 198 | const json = JSON.stringify({ username: USERNAME, password: PASSWORD }); | |
| 199 | anonymous.push( | |
| 200 | ...(await pool(routes(), (r) => { | |
| 201 | const path = pathFor(r.pattern); | |
| 202 | if (!MUTATING.has(r.method)) return send(r.method, path, {}); | |
| 203 | return r.api | |
| 204 | ? send(r.method, path, { origin: EVIL, "content-type": "application/json" }, json) | |
| 205 | : send(r.method, path, { origin: EVIL, "content-type": "application/x-www-form-urlencoded" }, form); | |
| 206 | })), | |
| 207 | ); | |
| 208 | // Plus what answers before any route (the probes) and a path no route claims, studio and API. | |
| 209 | for (const path of ["/healthz", "/readyz", "/no/such/path/at/all", "/api/v1/no-such-route"]) | |
| 210 | anonymous.push(await send("GET", path, {})); | |
| 211 | expect(floorGaps(anonymous)).toEqual([]); | |
| 212 | expect(notHttpOnly(anonymous)).toEqual([]); | |
| 213 | expect(anonymous.filter((s) => s.cookies.some((c) => /^forge_session=[^;]/.test(c))).map((s) => s.label)).toEqual([]); | |
| 214 | // The kinds the floor must hold on were all met, so a seed that stops | |
| 215 | // reaching one is noticed rather than passing vacuously. | |
| 216 | const statuses = [...new Set(anonymous.map((s) => s.status))]; | |
| 217 | expect(statuses.some((s) => s >= 200 && s < 300)).toBe(true); | |
| 218 | expect(statuses.some((s) => s >= 300 && s < 400)).toBe(true); | |
| 219 | expect(statuses).toContain(404); | |
| 220 | expect(statuses.some((s) => s >= 400 && s < 500 && s !== 404)).toBe(true); | |
| 221 | expect(anonymous.some((s) => s.type.startsWith("text/event-stream"))).toBe(true); | |
| 222 | }, 6_000); | |
| 223 | ||
| 224 | test("an API path asked with a verb it does not take answers 405, naming the ones it does, with the floor", async () => { | |
| 225 | const api = buildApiRouter(); | |
| 226 | const methods = new Map<string, Set<string>>(); | |
| 227 | for (const r of routes().filter((r) => r.api)) methods.set(r.pattern, (methods.get(r.pattern) ?? new Set()).add(r.method)); | |
| 228 | const asks = [...methods].flatMap(([pattern, taken]) => { | |
| 229 | const path = pathFor(pattern); | |
| 230 | const wrong = ["PATCH", "PUT", "DELETE", "POST", "GET"].find((m) => !taken.has(m) && api.match(m, path) === undefined); | |
| 231 | return wrong === undefined ? [] : [{ path, wrong }]; | |
| 232 | }); | |
| 233 | wrongMethod.push(...(await pool(asks, (a) => send(a.wrong, a.path, {})))); | |
| 234 | // A studio path's wrong verb is the same not-found page as any unknown path. | |
| 235 | wrongMethod.push(await send("PATCH", "/", {})); | |
| 236 | expect(floorGaps(wrongMethod)).toEqual([]); | |
| 237 | const wrongAnswers = asks | |
| 238 | .map((a, i) => ({ a, s: wrongMethod[i]! })) | |
| 239 | .filter(({ a, s }) => | |
| 240 | NO_405[a.path] !== undefined ? s.status !== 404 : s.status !== 405 || s.allow !== api.allowed(a.path).join(", "), | |
| 241 | ) | |
| 242 | .map(({ s }) => `${s.label} allow=${s.allow}`); | |
| 243 | expect(wrongAnswers).toEqual([]); | |
| 244 | expect(wrongMethod.at(-1)!.status).toBe(404); | |
| 245 | }, 2_000); | |
| 246 | ||
| 247 | test("a cross-origin mutation with a session cookie is refused before any handler, and the session lives on", async () => { | |
| 248 | const mutations = routes().filter((r) => MUTATING.has(r.method)); | |
| 249 | const seen = await pool(mutations, (r) => | |
| 250 | send(r.method, pathFor(r.pattern), { origin: EVIL, cookie: session, "content-type": "application/x-www-form-urlencoded" }, ""), | |
| 251 | ); | |
| 252 | expect(floorGaps(seen)).toEqual([]); | |
| 253 | expect(seen.filter((s) => s.status !== 403).map((s) => s.label)).toEqual([]); | |
| 254 | expect(seen.filter((s) => s.cookies.length > 0).map((s) => `${s.label} sets ${s.cookies.map(cookieName).join(", ")}`)).toEqual([]); | |
| 255 | const still = sessionOf(app.db, token); | |
| 256 | expect(still.ok && still.value.user.username).toBe(USERNAME); | |
| 257 | }, 2_000); | |
| 258 | ||
| 259 | test("signing in from the forge's own page sets the session cookie HttpOnly, with the floor", async () => { | |
| 260 | const form = await send( | |
| 261 | "POST", "/signin", | |
| 262 | { origin: servers.url, "content-type": "application/x-www-form-urlencoded" }, | |
| 263 | new URLSearchParams({ username: USERNAME, password: PASSWORD }).toString(), | |
| 264 | ); | |
| 265 | const api = await send( | |
| 266 | "POST", "/api/v1/sessions", | |
| 267 | { origin: servers.url, "content-type": "application/json" }, | |
| 268 | JSON.stringify({ username: USERNAME, password: PASSWORD }), | |
| 269 | ); | |
| 270 | for (const s of [form, api]) { | |
| 271 | expect(s.cookies.some((c) => /^forge_session=[^;]/.test(c))).toBe(true); | |
| 272 | expect(notHttpOnly([s])).toEqual([]); | |
| 273 | expect(s.missing).toEqual([]); | |
| 274 | } | |
| 275 | }, 1_000); | |
| 276 | ||
| 277 | test("every named exception is still an exception, each with its reason", () => { | |
| 278 | const all = [...anonymous, ...wrongMethod]; | |
| 279 | for (const [path, why] of Object.entries(FLOOR_EXCEPTIONS)) { | |
| 280 | expect(why.length).toBeGreaterThan(10); | |
| 281 | expect(all.some((s) => s.label.split(" ")[1] === path && s.missing.length > 0)).toBe(true); | |
| 282 | } | |
| 283 | for (const why of [...Object.values(READABLE_COOKIES), ...Object.values(NO_405)]) expect(why.length).toBeGreaterThan(10); | |
| 284 | }); |