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

Merged opened this

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

Merged

mason/forge:mason/10401-the-security-floor-route-sweep-is-timed → main 2 commits fork

  • Merged. mason/10401-the-security-floor-route-sweep-is-timed was merged into main as 69a8a96.

Review

Sign in to review this change.
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 @@
77 * A pattern that needs a parameter the seed cannot supply is named in
88 * `UNSEEDED` with why — it is still requested, with a placeholder, so its
99 * 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.
1014 */
1115 import { afterAll, beforeAll, expect, test } from "bun:test";
1216 import { Database } from "bun:sqlite";
@@ -27,6 +31,7 @@ import { repoDir } from "../src/git/repos.ts";
2731 import { buildApiRouter, buildRouter, serve, type Servers } from "../src/http/server.ts";
2832 import { seedAdmin } from "./helpers/admin.ts";
2933 import { quietLogs } from "./helpers/globals.ts";
34 import { pool, SWEEP_CEILING_MS, timed } from "./helpers/sweep-clock.ts";
3035
3136 quietLogs();
3237
@@ -218,10 +223,14 @@ async function floor(path: string, headers: Record<string, string>): Promise<str
218223
219224 const allPaths = (): string[] => [...getPatterns().map(pathFor), "/no/such/path/at/all", "/api/v1/no-such-route"];
220225
226 /** Milliseconds the two passes' request loops took, summed; boot and seed are not counted. */
227 let loopMs = 0;
228
221229 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();
225234 }
226235
227236 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
232241 expect(await missingFloor({ cookie })).toEqual([]);
233242 }, 9_000);
234243
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
235249 test("the patterns the seed cannot fill are exactly the ones named, each with a reason", () => {
236250 const computed = getPatterns().filter(unseeded).sort();
237251 expect(computed).toEqual(Object.keys(UNSEEDED).sort());
test/security-sweep.test.ts +43 −35
@@ -26,11 +26,11 @@
2626 * the floor as much as a hit. `test/security-floor-routes.test.ts` fills
2727 * deeper (issues, pulls, runs, apps) for GET, signed in and out.
2828 *
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.
3434 *
3535 * Side effects stay in the scratch FORGE_APP_DATA: the bodies sent are empty
3636 * 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";
4848 import { buildApiRouter, buildRouter, serve, type Servers } from "../src/http/server.ts";
4949 import { seedAdmin } from "./helpers/admin.ts";
5050 import { quietLogs } from "./helpers/globals.ts";
51 import { pool, SWEEP_CEILING_MS, timed } from "./helpers/sweep-clock.ts";
5152
5253 quietLogs();
5354
@@ -159,21 +160,6 @@ async function send(method: string, path: string, headers: Record<string, string
159160 };
160161 }
161162
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
177163 const floorGaps = (seen: Seen[]): string[] =>
178164 seen
179165 .filter((s) => s.missing.length > 0 && FLOOR_EXCEPTIONS[s.label.split(" ")[1]!] === undefined)
@@ -189,6 +175,15 @@ const notHttpOnly = (seen: Seen[]): string[] =>
189175
190176 const anonymous: Seen[] = [];
191177 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 }
192187
193188 test("every registered route and verb, asked anonymously, answers with the floor and hands out no session", async () => {
194189 // 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
196191 // session (login CSRF), whatever it does with the rest.
197192 const form = new URLSearchParams({ username: USERNAME, password: PASSWORD }).toString();
198193 const json = JSON.stringify({ username: USERNAME, password: PASSWORD });
194 const all = routes();
199195 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 ])),
207207 );
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, {}));
211208 expect(floorGaps(anonymous)).toEqual([]);
212209 expect(notHttpOnly(anonymous)).toEqual([]);
213210 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
230227 const wrong = ["PATCH", "PUT", "DELETE", "POST", "GET"].find((m) => !taken.has(m) && api.match(m, path) === undefined);
231228 return wrong === undefined ? [] : [{ path, wrong }];
232229 });
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 );
236237 expect(floorGaps(wrongMethod)).toEqual([]);
237238 const wrongAnswers = asks
238239 .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
246247
247248 test("a cross-origin mutation with a session cookie is refused before any handler, and the session lives on", async () => {
248249 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 ),
251254 );
252255 expect(floorGaps(seen)).toEqual([]);
253256 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
274277 }
275278 }, 1_000);
276279
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
277285 test("every named exception is still an exception, each with its reason", () => {
278286 const all = [...anonymous, ...wrongMethod];
279287 for (const [path, why] of Object.entries(FLOOR_EXCEPTIONS)) {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs