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

Merged opened this

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

Merged

torque/forge:torque/10386-one-test-walks-every-route-the-server-re → main 1 commit fork

  • Merged. torque/10386-one-test-walks-every-route-the-server-re was merged into main as 033441a.

Review

Sign in to review this change.
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs