Public: anyone can read this; the crew can change it.
The security-floor route sweep is timed: it fails when it takes more than 5 s #10416
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10401-the-security-floor-route-sweep-is-timed → main 2 commits fork
3 files
changed +94
−38
test/helpers/sweep-clock.ts added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | /** | |
| 2 | * The clock and the pool the route sweeps share (#10401): the security-floor | |
| 3 | * sweeps run on every push, so their request loops are held under | |
| 4 | * `SWEEP_CEILING_MS`. Only the loops are timed — never server boot or the | |
| 5 | * seed — so a loaded runner's slow start does not flake the bound. | |
| 6 | */ | |
| 7 | ||
| 8 | /** What the request loops of one sweep file may take together. */ | |
| 9 | export const SWEEP_CEILING_MS = 5_000; | |
| 10 | ||
| 11 | /** Run `loop`, and say how long it took in milliseconds. */ | |
| 12 | export async function timed<T>(loop: () => Promise<T>): Promise<{ value: T; ms: number }> { | |
| 13 | const start = performance.now(); | |
| 14 | const value = await loop(); | |
| 15 | return { value, ms: performance.now() - start }; | |
| 16 | } | |
| 17 | ||
| 18 | /** | |
| 19 | * Run every job, `width` at a time, answers in the items' order. The server | |
| 20 | * is one process, so more in flight buys nothing. | |
| 21 | */ | |
| 22 | export async function pool<T, R>(items: readonly T[], work: (item: T) => Promise<R>, width = 32): Promise<R[]> { | |
| 23 | const out: R[] = new Array(items.length); | |
| 24 | let next = 0; | |
| 25 | await Promise.all( | |
| 26 | Array.from({ length: Math.min(width, items.length) }, async () => { | |
| 27 | while (next < items.length) { | |
| 28 | const i = next++; | |
| 29 | out[i] = await work(items[i]!); | |
| 30 | } | |
| 31 | }), | |
| 32 | ); | |
| 33 | return out; | |
| 34 | } |
test/security-floor-routes.test.ts +17 −3
| @@ -7,6 +7,10 @@ | ||
| 7 | 7 | * A pattern that needs a parameter the seed cannot supply is named in |
| 8 | 8 | * `UNSEEDED` with why — it is still requested, with a placeholder, so its |
| 9 | 9 | * miss answers carry the floor too — and the list is checked both ways. |
| 10 | * | |
| 11 | * The two passes' request loops are timed together and held under 5 s | |
| 12 | * (`SWEEP_CEILING_MS`, #10401), 32 requests in flight against the one | |
| 13 | * server; server boot and the seed are not counted. | |
| 10 | 14 | */ |
| 11 | 15 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 12 | 16 | import { Database } from "bun:sqlite"; |
| @@ -27,6 +31,7 @@ import { repoDir } from "../src/git/repos.ts"; | ||
| 27 | 31 | import { buildApiRouter, buildRouter, serve, type Servers } from "../src/http/server.ts"; |
| 28 | 32 | import { seedAdmin } from "./helpers/admin.ts"; |
| 29 | 33 | import { quietLogs } from "./helpers/globals.ts"; |
| 34 | import { pool, SWEEP_CEILING_MS, timed } from "./helpers/sweep-clock.ts"; | |
| 30 | 35 | |
| 31 | 36 | quietLogs(); |
| 32 | 37 | |
| @@ -218,10 +223,14 @@ async function floor(path: string, headers: Record<string, string>): Promise<str | ||
| 218 | 223 | |
| 219 | 224 | const allPaths = (): string[] => [...getPatterns().map(pathFor), "/no/such/path/at/all", "/api/v1/no-such-route"]; |
| 220 | 225 | |
| 226 | /** Milliseconds the two passes' request loops took, summed; boot and seed are not counted. */ | |
| 227 | let loopMs = 0; | |
| 228 | ||
| 221 | 229 | async function missingFloor(headers: Record<string, string>): Promise<string[]> { |
| 222 | const missing: string[] = []; | |
| 223 | for (const path of allPaths()) missing.push(...(await floor(path, headers))); | |
| 224 | return missing; | |
| 230 | const paths = allPaths(); | |
| 231 | const { value, ms } = await timed(() => pool(paths, (path) => floor(path, headers))); | |
| 232 | loopMs += ms; | |
| 233 | return value.flat(); | |
| 225 | 234 | } |
| 226 | 235 | |
| 227 | 236 | test("every GET route, and an unknown path, carries the floor signed out", async () => { |
| @@ -232,6 +241,11 @@ test("every GET route, and an unknown path, carries the floor signed in", async | ||
| 232 | 241 | expect(await missingFloor({ cookie })).toEqual([]); |
| 233 | 242 | }, 9_000); |
| 234 | 243 | |
| 244 | test("the two passes' request loops together take under 5 s, so the sweep stays cheap enough for every push", () => { | |
| 245 | expect(loopMs).toBeGreaterThan(0); | |
| 246 | expect(loopMs).toBeLessThan(SWEEP_CEILING_MS); | |
| 247 | }); | |
| 248 | ||
| 235 | 249 | test("the patterns the seed cannot fill are exactly the ones named, each with a reason", () => { |
| 236 | 250 | const computed = getPatterns().filter(unseeded).sort(); |
| 237 | 251 | expect(computed).toEqual(Object.keys(UNSEEDED).sort()); |
test/security-sweep.test.ts +43 −35
| @@ -26,11 +26,11 @@ | ||
| 26 | 26 | * the floor as much as a hit. `test/security-floor-routes.test.ts` fills |
| 27 | 27 | * deeper (issues, pulls, runs, apps) for GET, signed in and out. |
| 28 | 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. | |
| 29 | * About 1 800 requests, 32 in flight against the one server. The three | |
| 30 | * passes' request loops are timed together and held under 5 s | |
| 31 | * (`SWEEP_CEILING_MS`, #10401); server boot and the seed are not counted. | |
| 32 | * The work is bounded by the route table, one request per (verb, route) | |
| 33 | * and pass. | |
| 34 | 34 | * |
| 35 | 35 | * Side effects stay in the scratch FORGE_APP_DATA: the bodies sent are empty |
| 36 | 36 | * or the seed's own credentials, and the cross-origin cookie pass is refused |
| @@ -48,6 +48,7 @@ import { createSession, sessionOf } from "../src/core/sessions.ts"; | ||
| 48 | 48 | import { buildApiRouter, buildRouter, serve, type Servers } from "../src/http/server.ts"; |
| 49 | 49 | import { seedAdmin } from "./helpers/admin.ts"; |
| 50 | 50 | import { quietLogs } from "./helpers/globals.ts"; |
| 51 | import { pool, SWEEP_CEILING_MS, timed } from "./helpers/sweep-clock.ts"; | |
| 51 | 52 | |
| 52 | 53 | quietLogs(); |
| 53 | 54 | |
| @@ -159,21 +160,6 @@ async function send(method: string, path: string, headers: Record<string, string | ||
| 159 | 160 | }; |
| 160 | 161 | } |
| 161 | 162 | |
| 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 | 163 | const floorGaps = (seen: Seen[]): string[] => |
| 178 | 164 | seen |
| 179 | 165 | .filter((s) => s.missing.length > 0 && FLOOR_EXCEPTIONS[s.label.split(" ")[1]!] === undefined) |
| @@ -189,6 +175,15 @@ const notHttpOnly = (seen: Seen[]): string[] => | ||
| 189 | 175 | |
| 190 | 176 | const anonymous: Seen[] = []; |
| 191 | 177 | const wrongMethod: Seen[] = []; |
| 178 | /** Milliseconds the three passes' request loops took, summed; boot and seed are not counted. */ | |
| 179 | let loopMs = 0; | |
| 180 | ||
| 181 | /** Time one pass's request loop and add it to `loopMs`. */ | |
| 182 | async function lap<T>(loop: () => Promise<T>): Promise<T> { | |
| 183 | const { value, ms } = await timed(loop); | |
| 184 | loopMs += ms; | |
| 185 | return value; | |
| 186 | } | |
| 192 | 187 | |
| 193 | 188 | test("every registered route and verb, asked anonymously, answers with the floor and hands out no session", async () => { |
| 194 | 189 | // A mutation is sent from another origin with the seed's real username |
| @@ -196,18 +191,20 @@ test("every registered route and verb, asked anonymously, answers with the floor | ||
| 196 | 191 | // session (login CSRF), whatever it does with the rest. |
| 197 | 192 | const form = new URLSearchParams({ username: USERNAME, password: PASSWORD }).toString(); |
| 198 | 193 | const json = JSON.stringify({ username: USERNAME, password: PASSWORD }); |
| 194 | const all = routes(); | |
| 199 | 195 | 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 | })), | |
| 196 | ...(await lap(async () => [ | |
| 197 | ...(await pool(all, (r) => { | |
| 198 | const path = pathFor(r.pattern); | |
| 199 | if (!MUTATING.has(r.method)) return send(r.method, path, {}); | |
| 200 | return r.api | |
| 201 | ? send(r.method, path, { origin: EVIL, "content-type": "application/json" }, json) | |
| 202 | : send(r.method, path, { origin: EVIL, "content-type": "application/x-www-form-urlencoded" }, form); | |
| 203 | })), | |
| 204 | // Plus what answers before any route (the probes) and a path no route claims, studio and API. | |
| 205 | ...(await pool(["/healthz", "/readyz", "/no/such/path/at/all", "/api/v1/no-such-route"], (path) => send("GET", path, {}))), | |
| 206 | ])), | |
| 207 | 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 | 208 | expect(floorGaps(anonymous)).toEqual([]); |
| 212 | 209 | expect(notHttpOnly(anonymous)).toEqual([]); |
| 213 | 210 | expect(anonymous.filter((s) => s.cookies.some((c) => /^forge_session=[^;]/.test(c))).map((s) => s.label)).toEqual([]); |
| @@ -230,9 +227,13 @@ test("an API path asked with a verb it does not take answers 405, naming the one | ||
| 230 | 227 | const wrong = ["PATCH", "PUT", "DELETE", "POST", "GET"].find((m) => !taken.has(m) && api.match(m, path) === undefined); |
| 231 | 228 | return wrong === undefined ? [] : [{ path, wrong }]; |
| 232 | 229 | }); |
| 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", "/", {})); | |
| 230 | wrongMethod.push( | |
| 231 | ...(await lap(async () => [ | |
| 232 | ...(await pool(asks, (a) => send(a.wrong, a.path, {}))), | |
| 233 | // A studio path's wrong verb is the same not-found page as any unknown path. | |
| 234 | await send("PATCH", "/", {}), | |
| 235 | ])), | |
| 236 | ); | |
| 236 | 237 | expect(floorGaps(wrongMethod)).toEqual([]); |
| 237 | 238 | const wrongAnswers = asks |
| 238 | 239 | .map((a, i) => ({ a, s: wrongMethod[i]! })) |
| @@ -246,8 +247,10 @@ test("an API path asked with a verb it does not take answers 405, naming the one | ||
| 246 | 247 | |
| 247 | 248 | test("a cross-origin mutation with a session cookie is refused before any handler, and the session lives on", async () => { |
| 248 | 249 | 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" }, ""), | |
| 250 | const seen = await lap(() => | |
| 251 | pool(mutations, (r) => | |
| 252 | send(r.method, pathFor(r.pattern), { origin: EVIL, cookie: session, "content-type": "application/x-www-form-urlencoded" }, ""), | |
| 253 | ), | |
| 251 | 254 | ); |
| 252 | 255 | expect(floorGaps(seen)).toEqual([]); |
| 253 | 256 | expect(seen.filter((s) => s.status !== 403).map((s) => s.label)).toEqual([]); |
| @@ -274,6 +277,11 @@ test("signing in from the forge's own page sets the session cookie HttpOnly, wit | ||
| 274 | 277 | } |
| 275 | 278 | }, 1_000); |
| 276 | 279 | |
| 280 | test("the three passes' request loops together take under 5 s, so the sweep stays cheap enough for every push", () => { | |
| 281 | expect(loopMs).toBeGreaterThan(0); | |
| 282 | expect(loopMs).toBeLessThan(SWEEP_CEILING_MS); | |
| 283 | }); | |
| 284 | ||
| 277 | 285 | test("every named exception is still an exception, each with its reason", () => { |
| 278 | 286 | const all = [...anonymous, ...wrongMethod]; |
| 279 | 287 | for (const [path, why] of Object.entries(FLOOR_EXCEPTIONS)) { |