Public: anyone can read this; the crew can change it.
Every cookie the forge sets is HttpOnly, SameSite and Secure behind HTTPS: one test finds each place it sets one #11882
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11864-every-cookie-the-forge-sets-is-httponly → main 3 commits fork
1 file
changed +246
−28
test/cookie-flags.test.ts +246 −28
| @@ -3,6 +3,8 @@ | ||
| 3 | 3 | * Set-Cookie. HttpOnly and SameSite=Lax|Strict on each, and Secure whenever |
| 4 | 4 | * the public origin is https. Run for a forge on http and one declared https. |
| 5 | 5 | * The walk at the end checks every cookie spelled in src/, reached or not (#10968). |
| 6 | * Each cookie is scoped to `Path=/` unless COOKIES names a narrower path and | |
| 7 | * why, and a new place that writes one fails here with its file and line (#11864). | |
| 6 | 8 | */ |
| 7 | 9 | import { afterAll, describe, expect, setDefaultTimeout, test } from "bun:test"; |
| 8 | 10 | import { readFileSync } from "node:fs"; |
| @@ -13,48 +15,99 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 13 | 15 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 14 | 16 | import { signGrant } from "../src/apps/proxy.ts"; |
| 15 | 17 | import { newApp } from "../src/actions/apps.ts"; |
| 18 | import { createRepo } from "../src/actions/repos.ts"; | |
| 19 | import { addDependency } from "../src/core/dependencies.ts"; | |
| 20 | import { mintFirstAdmin } from "../src/core/first_admin.ts"; | |
| 21 | import { createIssue } from "../src/core/issues.ts"; | |
| 22 | import { createOrg } from "../src/core/orgs.ts"; | |
| 16 | 23 | import { createSession } from "../src/core/sessions.ts"; |
| 24 | import { recordStart } from "../src/core/starts.ts"; | |
| 25 | import { mintClaim } from "../src/spawn/claim.ts"; | |
| 17 | 26 | import { issueRecovery, markVerified } from "../src/core/verify.ts"; |
| 18 | 27 | import { createUser } from "../src/core/users.ts"; |
| 19 | 28 | import { serve, type Servers } from "../src/http/server.ts"; |
| 29 | import { setReturning } from "../src/settings/returning.ts"; | |
| 20 | 30 | import { quietLogs } from "./helpers/globals.ts"; |
| 21 | import { device } from "./helpers/passkey.ts"; | |
| 31 | import { device, passkeyFields } from "./helpers/passkey.ts"; | |
| 22 | 32 | |
| 23 | 33 | quietLogs(); |
| 24 | 34 | setDefaultTimeout(60_000); |
| 25 | 35 | |
| 36 | /** | |
| 37 | * Every cookie spelled in src/ (`name=value; Attribute`, see SPELLED below), | |
| 38 | * keyed by its file and its name as the source spells it, with how many lines | |
| 39 | * spell it there. `name` is what a browser sees (a RegExp when the name carries | |
| 40 | * the thing it is for); `door` is the walk above that drives it, or `why` says | |
| 41 | * why no walk does. `path` is set only where a cookie is narrower than `/`, | |
| 42 | * with its reason: a narrower path is stricter, never weaker. | |
| 43 | */ | |
| 44 | interface Spelled { | |
| 45 | name: string | RegExp; | |
| 46 | lines: number; | |
| 47 | door?: string; | |
| 48 | why?: string; | |
| 49 | path?: { path: string; why: string }; | |
| 50 | } | |
| 51 | const COOKIES: Record<string, Spelled> = { | |
| 52 | "src/web/pages/site.ts forge_session": { name: "forge_session", lines: 1, door: "password sign-in" }, | |
| 53 | "src/web/pages/site.ts forge_theme": { name: "forge_theme", lines: 2, door: "theme" }, | |
| 54 | "src/web/flash.ts ${FLASH_COOKIE}": { name: "forge_flash", lines: 2, door: "parent link callback" }, | |
| 55 | "src/http/server.ts forge_mode": { name: "forge_mode", lines: 1, door: "mode switch" }, | |
| 56 | "src/http/rp.ts ${STATE_COOKIE}": { | |
| 57 | name: "forge_rp", | |
| 58 | lines: 1, | |
| 59 | door: "parent sign-in start", | |
| 60 | path: { path: "/signin/parent", why: "the OpenID state is read only by /signin/parent/callback; no other page should carry it" }, | |
| 61 | }, | |
| 62 | "src/apps/proxy.ts ${grantCookieName(target)}": { name: /^forge_grant_[0-9a-z]{8}$/, lines: 2, door: "app grant" }, | |
| 63 | "src/apps/feedback.ts ${VISITOR_COOKIE}": { name: "forge_visitor", lines: 1, door: "app note" }, | |
| 64 | "src/perf/visitor_id.ts ${COOKIE}": { name: "forge_visitor", lines: 1, door: "page-view beacon" }, | |
| 65 | "src/web/gift_cookie.ts ${NAME}": { name: "forge_gift", lines: 2, door: "gift link" }, | |
| 66 | "src/web/pages/map.ts ${name}": { name: /^rm_[\w-]+$/, lines: 1, door: "roadmap" }, | |
| 67 | "src/web/pages/pricing.ts ${COOKIE}": { | |
| 68 | name: "forge_order", | |
| 69 | lines: 1, | |
| 70 | why: "a paid order needs a payment provider to answer placeOrder; HttpOnly, Lax, Secure over https, checked in the source walk", | |
| 71 | path: { path: "/pricing", why: "the order ticket is read only by /pricing pages; no other page should carry it" }, | |
| 72 | }, | |
| 73 | }; | |
| 74 | ||
| 75 | /** The entry for a cookie by the name a browser sees, if one names it. */ | |
| 76 | const entryFor = (name: string): Spelled | undefined => | |
| 77 | Object.values(COOKIES).find((c) => (typeof c.name === "string" ? c.name === name : c.name.test(name))); | |
| 78 | ||
| 26 | 79 | /** |
| 27 | 80 | * Cookie-setting sites, counted per file (imports included) as `set-cookie` headers and |
| 28 | 81 | * sessionCookie() calls. WALKED files have every site exercised above (by the |
| 29 | 82 | * door named); UNREACHED ones are named with why. The scan below compares |
| 30 | * these counts to the source exactly, so a new site in a listed file fails | |
| 31 | * until it is walked here or named. | |
| 83 | * these counts to the source exactly and names the lines of any file that | |
| 84 | * differs, so a new site fails until it is walked here or named. | |
| 32 | 85 | */ |
| 33 | 86 | const WALKED: Record<string, number> = { |
| 34 | 87 | "src/http/api/tokens.ts": 4, // password sign-in, sign-out: a set-cookie header and a sessionCookie() call each |
| 35 | 88 | "src/http/server.ts": 1, // the mode switch |
| 36 | 89 | "src/http/rp.ts": 6, // parent sign-in and link: state cookie set and cleared |
| 37 | 90 | "src/apps/proxy.ts": 2, // app grant, app sign-out |
| 91 | "src/apps/feedback.ts": 2, // a stranger's note on an app host, both answers that may carry the visitor cookie | |
| 38 | 92 | "src/web/pages/passkeys.ts": 5, // import, passkey sign-in, passkey add/recovery-link sign-in, access-link sign-in (+1 mention) |
| 39 | 93 | "src/web/pages/site.ts": 4, // sessionCookie() itself and its uses: password sign-in/sign-up, logout; the theme cookie |
| 94 | "src/web/pages/first-admin.ts": 1, // the first admin made by passkey | |
| 95 | "src/web/pages/signup-passkey.ts": 2, // import, sign-up by passkey | |
| 96 | "src/web/pages/map.ts": 1, // the roadmap's last-looked cookie | |
| 40 | 97 | "src/web/layout.ts": 4, // the flash set on, and cleared from, a redirect |
| 41 | 98 | "src/web/flash.ts": 2, // forge_flash |
| 99 | "src/web/gift_cookie.ts": 1, // the gift link keeps its code | |
| 100 | "src/web/pages/claim.ts": 1, // the claim link of a spawned forge | |
| 101 | "src/web/pages/start_claim.ts": 1, // an agent's start taken with a new account | |
| 102 | "src/web/pages/everyday.ts": 2, // the everyday account made, and the switch that ends the admin's session | |
| 103 | "src/http/measure.ts": 1, // the page-view beacon | |
| 104 | "src/http/visitor.ts": 1, // the doc comment of trackVisitor, whose line the beacon sends | |
| 105 | "src/perf/visitor_id.ts": 1, // the doc comment of cookieLine, whose line the beacon sends | |
| 42 | 106 | }; |
| 43 | 107 | const UNREACHED: Record<string, { sites: number; why: string }> = { |
| 44 | 108 | "src/http/addressed.ts": { sites: 1, why: "only asks whether a response sets a cookie, so it isn't shared; it sets none" }, |
| 45 | "src/apps/feedback.ts": { sites: 2, why: "the visitor cookie is set on a deployed app's own host from its feedback widget; covered by the feedback tests" }, | |
| 46 | "src/web/pages/claim.ts": { sites: 1, why: "claiming the first admin seat needs a fresh forge with a claim link; same sessionCookie() as the walked doors" }, | |
| 47 | "src/web/pages/first-admin.ts": { sites: 1, why: "first-admin recovery needs a forge with no admin; same sessionCookie() as the walked doors" }, | |
| 48 | "src/web/pages/start_claim.ts": { sites: 1, why: "the claim of an agent's start needs a one-time token; same sessionCookie() as the walked doors; covered by test/start.test.ts" }, | |
| 49 | "src/web/pages/signup-passkey.ts": { sites: 2, why: "sign-up by passkey needs an invite; same sessionCookie() as the walked doors" }, | |
| 50 | "src/web/pages/map.ts": { sites: 1, why: "the map's last-looked cookie holds two timestamps, HttpOnly and Lax, and grants nothing; covered by test/roadmap.test.ts (name, HttpOnly, SameSite, no Secure over http)" }, | |
| 51 | "src/http/measure.ts": { sites: 1, why: "the page-view beacon answers with the visitor cookie line from src/http/visitor.ts; HttpOnly, Lax, Secure over https from cookieLine; covered by test/visitors.test.ts" }, | |
| 52 | "src/http/visitor.ts": { sites: 1, why: "the doc comment's mention of the Set-Cookie line it returns; covered by test/visitors.test.ts" }, | |
| 53 | "src/perf/visitor_id.ts": { sites: 1, why: "cookieLine itself, the Set-Cookie line for forge_visitor: HttpOnly, SameSite=Lax, Secure over https; covered by test/visitors.test.ts" }, | |
| 54 | 109 | "src/release/journey.ts": { sites: 1, why: "reads the Set-Cookie of the sign-in response to carry the session through the walk; sets none; covered by test/release-journey.test.ts" }, |
| 55 | "src/web/gift_cookie.ts": { sites: 1, why: "the gift code rides an HttpOnly, Lax cookie (Secure over https) and grants nothing by itself; covered by the gift code tests" }, | |
| 56 | "src/web/pages/new-forge.ts": { sites: 1, why: "appends dropGift(), the gift cookie's clearing line from src/web/gift_cookie.ts" }, | |
| 57 | "src/web/pages/everyday.ts": { sites: 2, why: "the admin/everyday switch needs a paired account; same sessionCookie() as the walked doors" }, | |
| 110 | "src/web/pages/new-forge.ts": { sites: 1, why: "appends dropGift(), the gift cookie's clearing line from src/web/gift_cookie.ts, once a forge is made with a gift code" }, | |
| 58 | 111 | }; |
| 59 | 112 | |
| 60 | 113 | const dirs: string[] = []; |
| @@ -64,20 +117,32 @@ afterAll(async () => { | ||
| 64 | 117 | for (const dir of dirs) await rm(dir, { recursive: true, force: true }); |
| 65 | 118 | }); |
| 66 | 119 | |
| 120 | const CHROME = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; | |
| 121 | ||
| 67 | 122 | const tmp = async () => { |
| 68 | 123 | const dir = await mkdtemp(join(tmpdir(), "forge-cookies-")); |
| 69 | 124 | dirs.push(dir); |
| 70 | 125 | return dir; |
| 71 | 126 | }; |
| 72 | 127 | |
| 128 | /** Each door's label and the cookie names it set, over http and https: what the coverage test reads. */ | |
| 129 | const driven = new Map<string, Set<string>>(); | |
| 130 | ||
| 73 | 131 | function check(label: string, cookies: string[], https: boolean) { |
| 74 | 132 | expect(cookies.length).toBeGreaterThan(0); |
| 75 | 133 | for (const cookie of cookies) { |
| 76 | 134 | const flags = cookie.split(";").slice(1).map((f) => f.trim().toLowerCase()); |
| 77 | const where = `${label}: ${cookie.split("=")[0]}`; | |
| 78 | expect([where, flags.includes("httponly")]).toEqual([where, true]); | |
| 135 | const name = cookie.split("=")[0]!; | |
| 136 | const where = `${label}: ${name}`; | |
| 137 | const entry = entryFor(name); | |
| 138 | expect([where, entry === undefined ? "a cookie COOKIES does not name" : "named"]).toEqual([where, "named"]); | |
| 139 | // A cookie a page script must read goes on EXEMPT with its reason; none does today. | |
| 140 | if (EXEMPT[name] === undefined) expect([where, flags.includes("httponly")]).toEqual([where, true]); | |
| 79 | 141 | expect([where, flags.includes("samesite=lax") || flags.includes("samesite=strict")]).toEqual([where, true]); |
| 80 | 142 | expect([where, flags.includes("secure")]).toEqual([where, https]); |
| 143 | // Exactly one Path, and it is `/` unless the entry scopes it narrower. | |
| 144 | expect([where, flags.filter((f) => f.startsWith("path="))]).toEqual([where, [`path=${(entry?.path?.path ?? "/").toLowerCase()}`]]); | |
| 145 | driven.set(label, (driven.get(label) ?? new Set()).add(name)); | |
| 81 | 146 | } |
| 82 | 147 | } |
| 83 | 148 | |
| @@ -140,6 +205,13 @@ for (const https of [false, true]) { | ||
| 140 | 205 | expect(pk.status).toBe(200); |
| 141 | 206 | take("passkey sign-in", pk, "forge_session"); |
| 142 | 207 | |
| 208 | // Sign-up by passkey: a new account, signed in. | |
| 209 | const sd = await device(origin); | |
| 210 | const so = (await (await jsonPost("/signup/passkey/options", { username: "wren", email: "" })).json()) as { challenge: string }; | |
| 211 | const signedUp = await jsonPost("/signup/passkey", { username: "wren", email: "", challenge: so.challenge, ...(await sd.create(so.challenge)) }); | |
| 212 | expect(signedUp.status).toBe(201); | |
| 213 | take("passkey sign-up", signedUp, "forge_session"); | |
| 214 | ||
| 143 | 215 | // Recovery link: a new passkey registered from the emailed link signs in. |
| 144 | 216 | markVerified(child.db, carol.id, new Date().toISOString()); |
| 145 | 217 | const token = issueRecovery(child.db, carol.id, Date.now())!; |
| @@ -159,7 +231,11 @@ for (const https of [false, true]) { | ||
| 159 | 231 | }); |
| 160 | 232 | return new URL(res.headers.get("location")!); |
| 161 | 233 | }; |
| 162 | const callback = (url: URL, cookie: string) => fetch(`${cs.url}${url.pathname}${url.search}`, { redirect: "manual", headers: { cookie } }); | |
| 234 | const callback = (url: URL, cookie: string) => { | |
| 235 | // The state cookie's narrower Path must still reach the callback that reads it. | |
| 236 | expect(url.pathname.startsWith(COOKIES["src/http/rp.ts ${STATE_COOKIE}"]!.path!.path)).toBe(true); | |
| 237 | return fetch(`${cs.url}${url.pathname}${url.search}`, { redirect: "manual", headers: { cookie } }); | |
| 238 | }; | |
| 163 | 239 | const link = await post("/settings/parent/link", { cookie: carol.cookie }); |
| 164 | 240 | expect(link.status).toBe(303); |
| 165 | 241 | const linkState = take("parent link start", link, "forge_rp"); |
| @@ -186,24 +262,147 @@ for (const https of [false, true]) { | ||
| 186 | 262 | take("app grant", grant); |
| 187 | 263 | take("app sign-out", await via("/__forge/signout")); |
| 188 | 264 | |
| 265 | // A stranger's note from a public app's page: the anonymous visitor cookie. | |
| 266 | const noted = await newApp(child, olive.user, { name: "cookienote", visibility: "public" }); | |
| 267 | if (!noted.ok) throw new Error(noted.error); | |
| 268 | const scheme = https ? "https" : "http"; | |
| 269 | const note = await fetch(`${cs.url}/__forge/note`, { | |
| 270 | method: "POST", | |
| 271 | redirect: "manual", | |
| 272 | headers: { | |
| 273 | host: "cookienote.apps.test", | |
| 274 | origin: `${scheme}://cookienote.apps.test`, | |
| 275 | "content-type": "application/json", | |
| 276 | ...(https ? { "x-forwarded-proto": "https" } : {}), | |
| 277 | }, | |
| 278 | body: JSON.stringify({ text: "The cookie note", page: `${scheme}://cookienote.apps.test/`, viewport: "390x844" }), | |
| 279 | }); | |
| 280 | expect(note.status).toBe(201); | |
| 281 | take("app note", note, "forge_visitor"); | |
| 282 | ||
| 283 | // A gift link keeps its code in a cookie, out of the next address. | |
| 284 | const gift = await fetch(`${cs.url}/?code=gift-cookie-1`, { redirect: "manual" }); | |
| 285 | expect(gift.status).toBe(303); | |
| 286 | take("gift link", gift, "forge_gift"); | |
| 287 | ||
| 288 | // The page-view beacon gives a returning-visitor id when the forge counts them. | |
| 289 | setReturning(child.db, true, olive.id); | |
| 290 | const beacon = await fetch(`${cs.url}/_measure`, { | |
| 291 | method: "POST", | |
| 292 | headers: { origin, "user-agent": CHROME, "accept-language": "en-GB" }, | |
| 293 | body: JSON.stringify({ path: "/", ttfb: 40, load: 300, lcp: 250, inp: 80, cls: 0.02 }), | |
| 294 | }); | |
| 295 | expect(beacon.status).toBe(204); | |
| 296 | take("page-view beacon", beacon, "forge_visitor"); | |
| 297 | ||
| 298 | // The roadmap of a public repository with a pillar marks when it was last looked at. | |
| 299 | const repo = await createRepo(child, olive.user, { name: "cookiemap", visibility: "public" }); | |
| 300 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 301 | const issue = (title: string) => { | |
| 302 | const made = createIssue(child.db, { repoId: repo.value.id, authorId: olive.id, title, body: "" }); | |
| 303 | if (!made.ok) throw new Error(made.error); | |
| 304 | return made.value; | |
| 305 | }; | |
| 306 | if (!addDependency(child.db, issue("P0 Trust: contained"), issue("Work"), olive.id).ok) throw new Error("dependency"); | |
| 307 | const map = await fetch(`${cs.url}/olive/cookiemap/map`, { redirect: "manual" }); | |
| 308 | expect(map.status).toBe(200); | |
| 309 | take("roadmap", map, "rm_olive_cookiemap"); | |
| 310 | ||
| 311 | // The first admin of a forge with none, made by passkey from a one-time code. | |
| 312 | const lone = createApp({ dataDir: await tmp(), port: 0, ...(public_ ? { url: public_ } : {}) }); | |
| 313 | const ls: Servers = await serve(lone); | |
| 314 | stops.push(() => ls.stop()); | |
| 315 | const loneOrigin = public_ ?? ls.url; | |
| 316 | const who = { token: mintFirstAdmin(lone.db)!.code, username: "ada", email: "[email protected]" }; | |
| 317 | const fields = await passkeyFields(ls.url, "/first-admin/options", who, { headers: { origin: loneOrigin }, device: await device(loneOrigin) }); | |
| 318 | const first = await fetch(`${ls.url}/first-admin`, { | |
| 319 | method: "POST", | |
| 320 | redirect: "manual", | |
| 321 | headers: { origin: loneOrigin }, | |
| 322 | body: new URLSearchParams({ ...who, ...fields }), | |
| 323 | }); | |
| 324 | expect(first.status).toBe(303); | |
| 325 | take("first admin", first, "forge_session"); | |
| 326 | ||
| 327 | // A spawned forge's claim link, seeded as spawn leaves it (test/claim-link.test.ts | |
| 328 | // walks the whole spawn): an org with a placeholder founder, parent.json, a live link. | |
| 329 | const kinDir = await tmp(); | |
| 330 | const kin = createApp({ dataDir: kinDir, port: 0, ...(public_ ? { url: public_ } : {}) }); | |
| 331 | const ks: Servers = await serve(kin); | |
| 332 | stops.push(() => ks.stop()); | |
| 333 | const kinOrigin = public_ ?? ks.url; | |
| 334 | const founder = await mk(kin, "founder"); | |
| 335 | const org = createOrg(kin.db, "kin", founder.id); | |
| 336 | if (!org.ok) throw new Error(org.error); | |
| 337 | await Bun.write(join(kinDir, "parent.json"), JSON.stringify({ org: "kin" })); | |
| 338 | const kinPost = (path: string, form: Record<string, string>, cookie?: string) => | |
| 339 | fetch(`${ks.url}${path}`, { method: "POST", redirect: "manual", headers: { origin: kinOrigin, ...(cookie ? { cookie } : {}) }, body: new URLSearchParams(form) }); | |
| 340 | const claimed = await kinPost("/claim", { token: mintClaim(kin.db, org.value.id), username: "kin-owner", email: "[email protected]", password: "a-long-claim-passphrase" }); | |
| 341 | expect(claimed.status).toBe(303); | |
| 342 | const owner = cookieOf(take("claim link", claimed, "forge_session"), "forge_session"); | |
| 343 | ||
| 344 | // The everyday account made from the admin's, then the switch back to sign-in. | |
| 345 | const daily = { username: "kin-me", email: "[email protected]" }; | |
| 346 | const dailyKey = await passkeyFields(ks.url, "/settings/everyday/options", daily, { headers: { origin: kinOrigin, cookie: owner }, device: await device(kinOrigin) }); | |
| 347 | const everyday = await kinPost("/settings/everyday", { ...daily, ...dailyKey }, owner); | |
| 348 | expect(everyday.status).toBe(303); | |
| 349 | take("everyday account", everyday, "forge_session"); | |
| 350 | const switched = await kinPost("/settings/everyday/switch", {}, owner); | |
| 351 | expect(switched.status).toBe(303); | |
| 352 | take("everyday switch", switched, "forge_session"); | |
| 353 | ||
| 354 | // An agent's start, taken by a stranger who makes an account on the spot. | |
| 355 | const agent = await mk(kin, "agent-start"); | |
| 356 | const startToken = "claim_cookie-flags-start"; | |
| 357 | recordStart(kin.db, agent.id, startToken, "cookie-flags"); | |
| 358 | const started = await kinPost(`/claim/start/${startToken}/account`, { username: "taker", email: "[email protected]", password: "a-long-start-passphrase" }); | |
| 359 | expect(started.status).toBe(303); | |
| 360 | take("start claim", started, "forge_session"); | |
| 361 | ||
| 189 | 362 | expect(new Set(seen.map((c) => c.split("=")[0])).size).toBeGreaterThanOrEqual(5); |
| 190 | 363 | }); |
| 191 | 364 | }); |
| 192 | 365 | } |
| 193 | 366 | |
| 194 | 367 | describe("cookie-setting sites", () => { |
| 195 | test("every file's cookie sites are walked above or named, counted exactly", async () => { | |
| 196 | const found: Record<string, number> = {}; | |
| 368 | test("every file's cookie sites are walked above or named, counted exactly; a stray one is named by file and line", async () => { | |
| 369 | const SITE = /["']set-cookie["']|Set-Cookie|sessionCookie\(/gi; | |
| 370 | const found: Record<string, string[]> = {}; | |
| 197 | 371 | for await (const file of new Bun.Glob("src/**/*.ts").scan(".")) { |
| 198 | const text = await Bun.file(file).text(); | |
| 199 | const n = (text.match(/["']set-cookie["']|Set-Cookie|sessionCookie\(/gi) ?? []).length; | |
| 200 | if (n > 0) found[file] = n; | |
| 372 | const lines = (await Bun.file(file).text()).split("\n"); | |
| 373 | lines.forEach((line, i) => { | |
| 374 | const at = `${file}:${i + 1}`; | |
| 375 | (found[file] ??= []).push(...[...line.matchAll(SITE)].map(() => at)); | |
| 376 | }); | |
| 201 | 377 | } |
| 202 | 378 | const expected: Record<string, number> = { ...WALKED }; |
| 203 | 379 | for (const [file, { sites }] of Object.entries(UNREACHED)) expected[file] = sites; |
| 204 | expect(found).toEqual(expected); | |
| 380 | const off = [...new Set([...Object.keys(found), ...Object.keys(expected)])] | |
| 381 | .sort() | |
| 382 | .filter((file) => (found[file]?.length ?? 0) !== (expected[file] ?? 0)) | |
| 383 | .map((file) => `${file}: ${found[file]?.length ?? 0} sites, the test names ${expected[file] ?? 0} (${(found[file] ?? []).join(", ") || "none left"})`); | |
| 384 | expect(off).toEqual([]); | |
| 205 | 385 | for (const { why } of Object.values(UNREACHED)) expect(why.length).toBeGreaterThan(10); |
| 206 | 386 | }); |
| 387 | ||
| 388 | test("every cookie COOKIES names was set by a door above, or says why no door reaches it", () => { | |
| 389 | // Run alone (`-t`), no door has run: there is nothing to compare. | |
| 390 | if (driven.size === 0) return; | |
| 391 | const wrong: string[] = []; | |
| 392 | const sets = (door: string | undefined, c: Spelled) => | |
| 393 | [...(door === undefined ? [...driven.values()].flatMap((n) => [...n]) : (driven.get(door) ?? []))].some((n) => | |
| 394 | typeof c.name === "string" ? c.name === n : c.name.test(n), | |
| 395 | ); | |
| 396 | for (const [key, c] of Object.entries(COOKIES)) { | |
| 397 | if (c.door !== undefined && c.why !== undefined) wrong.push(`${key}: a door or a why, not both`); | |
| 398 | if (c.door !== undefined && !sets(c.door, c)) wrong.push(`${key}: the door "${c.door}" did not set ${c.name}`); | |
| 399 | if (c.door === undefined && sets(undefined, c)) wrong.push(`${key}: a door sets ${c.name}; name it and drop the why`); | |
| 400 | if (c.door === undefined && (c.why ?? "").length <= 10) wrong.push(`${key}: no door and no why`); | |
| 401 | if (c.path !== undefined && (c.path.path === "/" || !c.path.path.startsWith("/") || c.path.why.length <= 10)) | |
| 402 | wrong.push(`${key}: a narrower path names itself and its reason`); | |
| 403 | } | |
| 404 | expect(wrong).toEqual([]); | |
| 405 | }); | |
| 207 | 406 | }); |
| 208 | 407 | |
| 209 | 408 | /** |
| @@ -244,9 +443,11 @@ const BUILDERS: Record<string, string> = { | ||
| 244 | 443 | /** `name=value; Attribute`: a cookie spelled out, its name literal or `${…}`. */ |
| 245 | 444 | const SPELLED = /(\$\{[^}]+\}|[A-Za-z_][\w-]*)=[^;\n]*?;\s*(?:Path|Max-Age|Expires|Domain|HttpOnly|SameSite|Secure)\b/; |
| 246 | 445 | |
| 247 | /** The flags a cookie line lacks; empty when it has them all. */ | |
| 248 | function lacking(line: string): string[] { | |
| 446 | /** The flags a cookie line lacks; empty when it has them all. `path` is the Path it must name. */ | |
| 447 | function lacking(line: string, path = "/"): string[] { | |
| 249 | 448 | const lacks: string[] = []; |
| 449 | const paths = [...line.matchAll(/\bPath=([^;\s`"']*)/g)].map((m) => m[1]); | |
| 450 | if (paths.length !== 1 || paths[0] !== path) lacks.push(`Path=${path}`); | |
| 250 | 451 | if (!/\bHttpOnly\b/.test(line)) lacks.push("HttpOnly"); |
| 251 | 452 | if (!/\bSameSite=(?:Lax|Strict)\b/.test(line)) lacks.push("SameSite=Lax|Strict"); |
| 252 | 453 | // Literal, conditional (`? "; Secure" : ""`), or the `${secure}` a builder took from secureFor. |
| @@ -265,6 +466,22 @@ describe("cookie flags, walked in the source", () => { | ||
| 265 | 466 | }), |
| 266 | 467 | ); |
| 267 | 468 | |
| 469 | test("every cookie spelled in src/ has its entry in COOKIES, and no entry is stale", () => { | |
| 470 | const counts = new Map<string, string[]>(); | |
| 471 | for (const s of spelled) { | |
| 472 | const key = `${s.where.replace(/:\d+$/, "")} ${s.name}`; | |
| 473 | counts.set(key, [...(counts.get(key) ?? []), s.where]); | |
| 474 | } | |
| 475 | const wrong: string[] = []; | |
| 476 | for (const [key, at] of counts) { | |
| 477 | const entry = COOKIES[key]; | |
| 478 | if (entry === undefined) wrong.push(`${at.join(", ")}: a cookie \`${key.split(" ")[1]}\` with no entry in COOKIES`); | |
| 479 | else if (entry.lines !== at.length) wrong.push(`${at.join(", ")}: ${at.length} lines spell \`${key}\`, COOKIES says ${entry.lines}`); | |
| 480 | } | |
| 481 | for (const key of Object.keys(COOKIES)) if (!counts.has(key)) wrong.push(`${key}: in COOKIES but spelled nowhere in src/`); | |
| 482 | expect(wrong).toEqual([]); | |
| 483 | }); | |
| 484 | ||
| 268 | 485 | test("the walk finds the forge's cookies, including those no door above reaches", () => { |
| 269 | 486 | const where = new Set(spelled.map((s) => s.where.replace(/:\d+$/, ""))); |
| 270 | 487 | for (const file of ["src/web/pages/site.ts", "src/web/flash.ts", "src/http/rp.ts", "src/http/server.ts", "src/apps/proxy.ts", "src/apps/feedback.ts", "src/web/pages/map.ts"]) |
| @@ -274,7 +491,7 @@ describe("cookie flags, walked in the source", () => { | ||
| 274 | 491 | test("every cookie spelled in src/ is HttpOnly, SameSite and Secure, or exempt with a reason", () => { |
| 275 | 492 | const short = spelled |
| 276 | 493 | .filter((s) => EXEMPT[s.name] === undefined) |
| 277 | .map((s) => ({ ...s, lacks: lacking(s.line) })) | |
| 494 | .map((s) => ({ ...s, lacks: lacking(s.line, COOKIES[`${s.where.replace(/:\d+$/, "")} ${s.name}`]?.path?.path ?? "/") })) | |
| 278 | 495 | .filter((s) => s.lacks.length > 0) |
| 279 | 496 | .map((s) => `${s.where} ${s.name} lacks ${s.lacks.join(", ")}`); |
| 280 | 497 | expect(short).toEqual([]); |
| @@ -290,7 +507,8 @@ describe("cookie flags, walked in the source", () => { | ||
| 290 | 507 | const value = match[1]!.trim(); |
| 291 | 508 | const at = `${file}:${text.slice(0, match.index).split("\n").length}`; |
| 292 | 509 | if (value.startsWith("`")) { |
| 293 | const lacks = SPELLED.test(value) ? lacking(value) : ["every flag"]; | |
| 510 | const spelledName = SPELLED.exec(value)?.[1]; | |
| 511 | const lacks = spelledName !== undefined ? lacking(value, COOKIES[`${file} ${spelledName}`]?.path?.path ?? "/") : ["every flag"]; | |
| 294 | 512 | if (lacks.length > 0) unknown.push(`${at} inline cookie lacks ${lacks.join(", ")}`); |
| 295 | 513 | continue; |
| 296 | 514 | } |
new above
offline