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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/12313-every-open-route-answers-with-the-securi → main 6 commits fork
1 file
changed +128
−17
test/security-floor-routes.test.ts +128 −17
| @@ -1,7 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 5 | 10 | * The route table is read from the routers themselves; each pattern's |
| 6 | 11 | * parameters are filled from one seeded repository, issue, pull, run and app. |
| 7 | 12 | * A pattern that needs a parameter the seed cannot supply is named in |
| @@ -26,7 +31,9 @@ import { createPull } from "../src/core/pulls.ts"; | ||
| 26 | 31 | import { createRepo } from "../src/core/repos.ts"; |
| 27 | 32 | import { createRun } from "../src/core/runs.ts"; |
| 28 | 33 | import { createSession } from "../src/core/sessions.ts"; |
| 34 | import { createToken } from "../src/core/tokens.ts"; | |
| 29 | 35 | import { repoDir } from "../src/git/repos.ts"; |
| 36 | import { markOf } from "../src/http/route_marks.ts"; | |
| 30 | 37 | import { buildApiRouter, buildRouter, serve, type Servers } from "../src/http/server.ts"; |
| 31 | 38 | import { startChatBuild } from "../src/core/chat_build.ts"; |
| 32 | 39 | import { createConversation } from "../src/core/conversations.ts"; |
| @@ -143,6 +150,7 @@ const UNSEEDED: Record<string, string> = { | ||
| 143 | 150 | let dataDir = ""; |
| 144 | 151 | let app: App, servers: Servers; |
| 145 | 152 | let cookie = ""; |
| 153 | let bearer = ""; | |
| 146 | 154 | const fill: Record<string, string> = {}; |
| 147 | 155 | |
| 148 | 156 | beforeAll(async () => { |
| @@ -186,6 +194,7 @@ beforeAll(async () => { | ||
| 186 | 194 | startChatBuild(app.db, talk.value.id, repo.id); |
| 187 | 195 | fill.conversation = talk.value.id; |
| 188 | 196 | cookie = `forge_session=${createSession(app.db, ada.id).token}`; |
| 197 | bearer = `Bearer ${createToken(app.db, ada.id, "floor", "admin").token}`; | |
| 189 | 198 | }, 30_000); |
| 190 | 199 | |
| 191 | 200 | afterAll(async () => { |
| @@ -197,8 +206,24 @@ afterAll(async () => { | ||
| 197 | 206 | const paramsOf = (pattern: string): string[] => |
| 198 | 207 | [...pattern.matchAll(/[:*]([a-z_]+)/g)].map((m) => m[1]!); |
| 199 | 208 | |
| 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 | ||
| 200 | 225 | 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))]; | |
| 202 | 227 | |
| 203 | 228 | function pathFor(pattern: string): string { |
| 204 | 229 | const number = pattern.includes("/pulls/") ? fill.pull : fill.issue; |
| @@ -224,32 +249,118 @@ const unseeded = (pattern: string): boolean => | ||
| 224 | 249 | * is applied after compression (`compress(...).then(securityFloor)` in |
| 225 | 250 | * src/http/server.ts), so the headers asserted are the same either way. |
| 226 | 251 | */ |
| 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[]> { | |
| 228 | 253 | 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), | |
| 230 | 255 | }); |
| 231 | 256 | 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}`); | |
| 235 | 259 | } |
| 236 | 260 | |
| 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; | |
| 238 | 262 | |
| 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(); | |
| 241 | 273 | } |
| 242 | 274 | |
| 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); | |
| 246 | 279 | |
| 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 | }); | |
| 250 | 290 | |
| 251 | 291 | test("the patterns the seed cannot fill are exactly the ones named, each with a reason", () => { |
| 252 | 292 | const computed = getPatterns().filter(unseeded).sort(); |
| 253 | 293 | expect(computed).toEqual(Object.keys(UNSEEDED).sort()); |
| 254 | 294 | for (const why of Object.values(UNSEEDED)) expect(why.length).toBeGreaterThan(10); |
| 255 | 295 | }); |
| 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 | }); |
new above
offline