Public: anyone can read this; the crew can change it.
Every cookie the forge sets says HttpOnly, Secure and SameSite, or is on a short named list with its reason, proven by a test that walks the code #10972
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10968-every-cookie-the-forge-sets-says-httponl → main 1 commit fork
1 file
changed +108
−0
test/cookie-flags.test.ts +108
| @@ -2,8 +2,10 @@ | ||
| 2 | 2 | * Every cookie the forge sets (#5898): sign in by each door and read every |
| 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 | * The walk at the end checks every cookie spelled in src/, reached or not (#10968). | |
| 5 | 6 | */ |
| 6 | 7 | import { afterAll, describe, expect, setDefaultTimeout, test } from "bun:test"; |
| 8 | import { readFileSync } from "node:fs"; | |
| 7 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 10 | import { tmpdir } from "node:os"; |
| 9 | 11 | import { join } from "node:path"; |
| @@ -197,3 +199,109 @@ describe("cookie-setting sites", () => { | ||
| 197 | 199 | for (const { why } of Object.values(UNREACHED)) expect(why.length).toBeGreaterThan(10); |
| 198 | 200 | }); |
| 199 | 201 | }); |
| 202 | ||
| 203 | /** | |
| 204 | * The doors above prove the cookies a request can reach; this walk proves the | |
| 205 | * rest (#10968). Every cookie spelled in src/ (`name=value; Attribute`, outside | |
| 206 | * comments) says HttpOnly, SameSite=Lax|Strict and Secure (literal, or | |
| 207 | * conditioned on `secureFor`'s https rule), unless its name is on EXEMPT. | |
| 208 | */ | |
| 209 | ||
| 210 | /** | |
| 211 | * Cookies allowed to go without a flag, by name as the source spells it, one | |
| 212 | * line of reason each. Empty today: every cookie has every flag. A cookie a | |
| 213 | * first-party script must read (a theme painted before the server answers, | |
| 214 | * say) would go here; the session and any credential cookie never do. | |
| 215 | */ | |
| 216 | const EXEMPT: Record<string, string> = {}; | |
| 217 | ||
| 218 | /** Names that carry a credential: never exempt, whatever the list says. */ | |
| 219 | const NEVER_EXEMPT = /session|token|grant|state|visitor/i; | |
| 220 | ||
| 221 | /** | |
| 222 | * What a `set-cookie` writer may pass when it is not an inline literal: a | |
| 223 | * builder whose own literal the walk checks, or a variable holding one. | |
| 224 | */ | |
| 225 | const BUILDERS: Record<string, string> = { | |
| 226 | "sessionCookie(": "src/web/pages/site.ts", | |
| 227 | "flashCookie(": "src/web/flash.ts", | |
| 228 | "clearFlashCookie(": "src/web/flash.ts", | |
| 229 | "cookieHeader(": "src/http/rp.ts", | |
| 230 | "visitorCookie(": "src/apps/feedback.ts", | |
| 231 | "cookie(": "src/apps/scaffold.ts, the scaffolded app's own `cookie`", | |
| 232 | clear: "src/http/rp.ts, `cookieHeader` with Max-Age 0", | |
| 233 | value: "src/apps/scaffold.ts, `redirectTo`'s cookies, each from `cookie`", | |
| 234 | "o.cookie": "src/web/layout.ts, a page's cookie (the theme literal in src/web/pages/site.ts)", | |
| 235 | }; | |
| 236 | ||
| 237 | /** `name=value; Attribute`: a cookie spelled out, its name literal or `${…}`. */ | |
| 238 | const SPELLED = /(\$\{[^}]+\}|[A-Za-z_][\w-]*)=[^;\n]*?;\s*(?:Path|Max-Age|Expires|Domain|HttpOnly|SameSite|Secure)\b/; | |
| 239 | ||
| 240 | /** The flags a cookie line lacks; empty when it has them all. */ | |
| 241 | function lacking(line: string): string[] { | |
| 242 | const lacks: string[] = []; | |
| 243 | if (!/\bHttpOnly\b/.test(line)) lacks.push("HttpOnly"); | |
| 244 | if (!/\bSameSite=(?:Lax|Strict)\b/.test(line)) lacks.push("SameSite=Lax|Strict"); | |
| 245 | // Literal, conditional (`? "; Secure" : ""`), or the `${secure}` a builder took from secureFor. | |
| 246 | if (!/\bSecure\b|\$\{secure\}/.test(line)) lacks.push("Secure"); | |
| 247 | return lacks; | |
| 248 | } | |
| 249 | ||
| 250 | describe("cookie flags, walked in the source", () => { | |
| 251 | const files = [...new Bun.Glob("src/**/*.ts").scanSync(".")].sort().map((file) => ({ file, text: readFileSync(file, "utf8") })); | |
| 252 | const spelled = files.flatMap(({ file, text }) => | |
| 253 | text.split("\n").flatMap((line, i) => { | |
| 254 | const trimmed = line.trim(); | |
| 255 | if (trimmed.startsWith("//") || trimmed.startsWith("*")) return []; | |
| 256 | const match = SPELLED.exec(line); | |
| 257 | return match ? [{ where: `${file}:${i + 1}`, name: match[1]!, line: trimmed }] : []; | |
| 258 | }), | |
| 259 | ); | |
| 260 | ||
| 261 | test("the walk finds the forge's cookies, including those no door above reaches", () => { | |
| 262 | const where = new Set(spelled.map((s) => s.where.replace(/:\d+$/, ""))); | |
| 263 | 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/apps/scaffold.ts", "src/web/pages/map.ts"]) | |
| 264 | expect([file, where.has(file)]).toEqual([file, true]); | |
| 265 | }); | |
| 266 | ||
| 267 | test("every cookie spelled in src/ is HttpOnly, SameSite and Secure, or exempt with a reason", () => { | |
| 268 | const short = spelled | |
| 269 | .filter((s) => EXEMPT[s.name] === undefined) | |
| 270 | .map((s) => ({ ...s, lacks: lacking(s.line) })) | |
| 271 | .filter((s) => s.lacks.length > 0) | |
| 272 | .map((s) => `${s.where} ${s.name} lacks ${s.lacks.join(", ")}`); | |
| 273 | expect(short).toEqual([]); | |
| 274 | }); | |
| 275 | ||
| 276 | test("every set-cookie writer passes a checked literal or a known builder", () => { | |
| 277 | const writer = /(?:\.(?:append|set)\(\s*["']set-cookie["']\s*,|["']set-cookie["']\s*:)\s*([^\n]+)/gi; | |
| 278 | const unknown: string[] = []; | |
| 279 | let writers = 0; | |
| 280 | for (const { file, text } of files) { | |
| 281 | for (const match of text.matchAll(writer)) { | |
| 282 | writers++; | |
| 283 | const value = match[1]!.trim(); | |
| 284 | const at = `${file}:${text.slice(0, match.index).split("\n").length}`; | |
| 285 | if (value.startsWith("`")) { | |
| 286 | const lacks = SPELLED.test(value) ? lacking(value) : ["every flag"]; | |
| 287 | if (lacks.length > 0) unknown.push(`${at} inline cookie lacks ${lacks.join(", ")}`); | |
| 288 | continue; | |
| 289 | } | |
| 290 | const known = Object.keys(BUILDERS).some((b) => | |
| 291 | b.endsWith("(") ? value.startsWith(b) : new RegExp(`^${b.replace(".", "\\.")}\\b`).test(value), | |
| 292 | ); | |
| 293 | if (!known) unknown.push(`${at} passes \`${value}\`: build it with a checked builder or spell it inline`); | |
| 294 | } | |
| 295 | } | |
| 296 | expect(writers).toBeGreaterThan(10); | |
| 297 | expect(unknown).toEqual([]); | |
| 298 | }); | |
| 299 | ||
| 300 | test("the exempt list holds no credential cookie and nothing stale", () => { | |
| 301 | for (const [name, reason] of Object.entries(EXEMPT)) { | |
| 302 | expect([name, NEVER_EXEMPT.test(name)]).toEqual([name, false]); | |
| 303 | expect(reason.trim().length).toBeGreaterThan(10); | |
| 304 | expect([name, spelled.some((s) => s.name === name)]).toEqual([name, true]); | |
| 305 | } | |
| 306 | }); | |
| 307 | }); |