Public: anyone can read this; the crew can change it.

Every open route answers with the security floor, 404s and errors included, proven by one test over the route table #12339

Merged opened this

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

Merged

torque/forge:torque/12313-every-open-route-answers-with-the-securi → main 6 commits fork

  • Merged. torque/12313-every-open-route-answers-with-the-securi was merged into main as 4ad7a6a.

Review

Sign in to review this change.
1 file changed +128 −17
test/security-floor-routes.test.ts +128 −17
@@ -1,7 +1,12 @@
11 /**
22 * The security floor (`securityFloor` in src/http/respond.ts) on every GET
3 * route the two routers register, signed out and signed in (one test each,
4 * over the same paths and the one seeded server), 404s included.
3 * route the two routers register, signed out and signed in (one test per
4 * part of the table and state, over the one seeded server, so no test walks
5 * the whole table under the 2 s test budget), 404s included; then on
6 * every route of any other method marked `open` in src/http/route_marks.ts
7 * (#12313) — cross-site, same-site signed out, and same-site signed in, so the
8 * origin refusal, the sign-in refusal and the handler's own answer are each
9 * walked — and on a forced 500 under an open page and an open API route.
510 * The route table is read from the routers themselves; each pattern's
611 * parameters are filled from one seeded repository, issue, pull, run and app.
712 * A pattern that needs a parameter the seed cannot supply is named in
@@ -26,7 +31,9 @@ import { createPull } from "../src/core/pulls.ts";
2631 import { createRepo } from "../src/core/repos.ts";
2732 import { createRun } from "../src/core/runs.ts";
2833 import { createSession } from "../src/core/sessions.ts";
34 import { createToken } from "../src/core/tokens.ts";
2935 import { repoDir } from "../src/git/repos.ts";
36 import { markOf } from "../src/http/route_marks.ts";
3037 import { buildApiRouter, buildRouter, serve, type Servers } from "../src/http/server.ts";
3138 import { startChatBuild } from "../src/core/chat_build.ts";
3239 import { createConversation } from "../src/core/conversations.ts";
@@ -143,6 +150,7 @@ const UNSEEDED: Record<string, string> = {
143150 let dataDir = "";
144151 let app: App, servers: Servers;
145152 let cookie = "";
153 let bearer = "";
146154 const fill: Record<string, string> = {};
147155
148156 beforeAll(async () => {
@@ -186,6 +194,7 @@ beforeAll(async () => {
186194 startChatBuild(app.db, talk.value.id, repo.id);
187195 fill.conversation = talk.value.id;
188196 cookie = `forge_session=${createSession(app.db, ada.id).token}`;
197 bearer = `Bearer ${createToken(app.db, ada.id, "floor", "admin").token}`;
189198 }, 30_000);
190199
191200 afterAll(async () => {
@@ -197,8 +206,24 @@ afterAll(async () => {
197206 const paramsOf = (pattern: string): string[] =>
198207 [...pattern.matchAll(/[:*]([a-z_]+)/g)].map((m) => m[1]!);
199208
209 /**
210 * The GET table in three parts, each walked by its own tests so none runs the
211 * whole table under the 2 s test budget: a repository's pages, the rest of the
212 * pages, and the API. Together they are every GET pattern (checked below).
213 */
214 const PARTS = {
215 "repository pages": () => getPatternsFrom(buildRouter).filter((p) => p.startsWith("/:owner/:name")),
216 "other pages": () => getPatternsFrom(buildRouter).filter((p) => !p.startsWith("/:owner/:name")),
217 "API": () => getPatternsFrom(buildApiRouter),
218 } as const;
219 type Part = keyof typeof PARTS;
220 const PART_NAMES = Object.keys(PARTS) as Part[];
221
222 const getPatternsFrom = (build: typeof buildRouter | typeof buildApiRouter): string[] =>
223 build().routes().filter((r) => r.method === "GET").map((r) => r.pattern);
224
200225 const getPatterns = (): string[] =>
201 [...new Set([buildRouter(), buildApiRouter()].flatMap((r) => r.routes()).filter((r) => r.method === "GET").map((r) => r.pattern))];
226 [...new Set([buildRouter, buildApiRouter].flatMap(getPatternsFrom))];
202227
203228 function pathFor(pattern: string): string {
204229 const number = pattern.includes("/pulls/") ? fill.pull : fill.issue;
@@ -224,32 +249,118 @@ const unseeded = (pattern: string): boolean =>
224249 * is applied after compression (`compress(...).then(securityFloor)` in
225250 * src/http/server.ts), so the headers asserted are the same either way.
226251 */
227 async function floor(path: string, headers: Record<string, string>): Promise<string[]> {
252 async function floor(path: string, headers: Record<string, string>, method = "GET", statuses?: number[]): Promise<string[]> {
228253 const res = await fetch(`${servers.url}${path}`, {
229 headers: { "accept-encoding": "identity", ...headers }, redirect: "manual", signal: AbortSignal.timeout(5_000),
254 method, headers: { "accept-encoding": "identity", ...headers }, redirect: "manual", signal: AbortSignal.timeout(5_000),
230255 });
231256 await res.body?.cancel();
232 return ["content-security-policy", "x-content-type-options", "referrer-policy"]
233 .filter((h) => !res.headers.get(h))
234 .map((h) => `${res.status} ${path} lacks ${h}`);
257 statuses?.push(res.status);
258 return FLOOR.filter((h) => !res.headers.get(h)).map((h) => `${res.status} ${method} ${path} lacks ${h}`);
235259 }
236260
237 const allPaths = (): string[] => [...getPatterns().map(pathFor), "/no/such/path/at/all", "/api/v1/no-such-route"];
261 const FLOOR = ["content-security-policy", "x-content-type-options", "referrer-policy"] as const;
238262
239 async function missingFloor(headers: Record<string, string>): Promise<string[]> {
240 return (await pool(allPaths(), (path) => floor(path, headers))).flat();
263 /** Each part's GET paths, and an unknown path that part's router answers. */
264 const UNKNOWN: Record<Part, string> = {
265 "repository pages": "/ada/notes/no/such/page",
266 "other pages": "/no/such/path/at/all",
267 "API": "/api/v1/no-such-route",
268 };
269 const pathsOf = (part: Part): string[] => [...new Set(PARTS[part]())].map(pathFor).concat(UNKNOWN[part]);
270
271 async function missingFloor(part: Part, headers: Record<string, string>): Promise<string[]> {
272 return (await pool(pathsOf(part), (path) => floor(path, headers))).flat();
241273 }
242274
243 test("every GET route, and an unknown path, carries the floor signed out", async () => {
244 expect(await missingFloor({})).toEqual([]);
245 }, 9_000);
275 for (const part of PART_NAMES) {
276 test(`every GET route of the ${part}, and an unknown path, carries the floor signed out`, async () => {
277 expect(await missingFloor(part, {})).toEqual([]);
278 }, 9_000);
246279
247 test("every GET route, and an unknown path, carries the floor signed in", async () => {
248 expect(await missingFloor({ cookie })).toEqual([]);
249 }, 9_000);
280 test(`every GET route of the ${part}, and an unknown path, carries the floor signed in`, async () => {
281 expect(await missingFloor(part, { cookie })).toEqual([]);
282 }, 9_000);
283 }
284
285 test("the three parts together walk every GET pattern, each a real share of it", () => {
286 const walked = new Set(PART_NAMES.flatMap((part) => PARTS[part]()));
287 expect(getPatterns().filter((p) => !walked.has(p))).toEqual([]);
288 for (const part of PART_NAMES) expect(`${part} ${PARTS[part]().length > 50}`).toBe(`${part} true`);
289 });
250290
251291 test("the patterns the seed cannot fill are exactly the ones named, each with a reason", () => {
252292 const computed = getPatterns().filter(unseeded).sort();
253293 expect(computed).toEqual(Object.keys(UNSEEDED).sort());
254294 for (const why of Object.values(UNSEEDED)) expect(why.length).toBeGreaterThan(10);
255295 });
296
297 /**
298 * Every route of a method other than GET that route_marks.ts marks `open`, its path filled as the
299 * GET walk fills it.
300 */
301 const openMutations = (): { method: string; path: string }[] =>
302 [buildRouter(), buildApiRouter()].flatMap((r) => r.routes())
303 .filter((r) => r.method !== "GET" && markOf(r.pattern) === "open")
304 .map((r) => ({ method: r.method, path: pathFor(r.pattern) }));
305
306 /**
307 * An empty form body: each handler answers what it answers to nothing — a
308 * refusal, a validation error, a redirect — and the floor must be on it.
309 * The seeded server is thrown away after the file, so what a call changes is
310 * nobody's; these run after the GET walks so they cannot change what those read.
311 */
312 async function missingOnMutations(headers: Record<string, string>, statuses?: number[]): Promise<string[]> {
313 const sent = { "content-type": "application/x-www-form-urlencoded", ...headers };
314 return (await pool(openMutations(), ({ method, path }) => floor(path, sent, method, statuses))).flat();
315 }
316
317 test("the open walk reaches every method the route table holds, and only open routes", () => {
318 const methods = new Set(openMutations().map((r) => r.method));
319 for (const m of ["POST", "PUT", "PATCH", "DELETE"]) expect(methods.has(m)).toBe(true);
320 // The GET walk covers every GET pattern, open and admin alike, so every open GET is in it.
321 const gets = new Set(getPatterns());
322 const open = [buildRouter(), buildApiRouter()].flatMap((r) => r.routes()).filter((r) => r.method === "GET" && markOf(r.pattern) === "open");
323 expect(open.length).toBeGreaterThan(100);
324 expect(open.filter((r) => !gets.has(r.pattern))).toEqual([]);
325 });
326
327 test("every open route of another method carries the floor when refused cross-site", async () => {
328 expect(await missingOnMutations({ origin: "https://elsewhere.example" })).toEqual([]);
329 }, 15_000);
330
331 test("every open route of another method carries the floor same-site, signed out", async () => {
332 expect(await missingOnMutations({ origin: servers.url })).toEqual([]);
333 }, 15_000);
334
335 /**
336 * Signed in by a session on the pages and by a token on the API: the API counts
337 * a cookie as no token, and the passes before this one have spent the address's
338 * tokenless allowance, so a cookie alone would read 429s, not the handlers.
339 */
340 test("every open route of another method carries the floor same-site, signed in", async () => {
341 const statuses: number[] = [];
342 expect(await missingOnMutations({ origin: servers.url, cookie, authorization: bearer }, statuses)).toEqual([]);
343 // The pass reached the handlers: no rate limit stood in front, and some acted.
344 expect(statuses.filter((s) => s === 429)).toEqual([]);
345 expect(statuses.filter((s) => s >= 200 && s < 300).length).toBeGreaterThan(5);
346 }, 15_000);
347
348 test("a forced 500 under an open page and an open API route carries the floor and names no fault", async () => {
349 expect(markOf("/explore")).toBe("open");
350 expect(markOf("/api/v1/user")).toBe("open");
351 const query = app.db.query.bind(app.db);
352 app.db.query = (() => {
353 throw new Error(`SQLITE_IOERR at ${dataDir}/forge.db`);
354 }) as typeof app.db.query;
355 try {
356 for (const path of ["/explore", "/api/v1/user"]) {
357 const res = await fetch(`${servers.url}${path}`, { headers: { cookie, accept: "text/html" }, redirect: "manual" });
358 const body = await res.text();
359 expect(`${path} ${res.status}`).toBe(`${path} 500`);
360 for (const h of FLOOR) expect(`${path} ${h}=${res.headers.get(h) ?? ""}`).not.toBe(`${path} ${h}=`);
361 expect(body).not.toContain("SQLITE_IOERR");
362 }
363 } finally {
364 app.db.query = query;
365 }
366 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs