Public: anyone can read this; the crew can change it.
Every cookie the forge sets is HttpOnly and SameSite, and Secure on https: a test signs in by each door and reads every Set-Cookie #5918
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5898-every-cookie-the-forge-sets-is-httponly → main 3 commits fork 1 thread · 1 unresolved
1 file
changed +197
−0
test/cookie-flags.test.ts added +197
| @@ -0,0 +1,197 @@ | ||
| 1 | /** | |
| 2 | * Every cookie the forge sets (#5898): sign in by each door and read every | |
| 3 | * Set-Cookie. HttpOnly and SameSite=Lax|Strict on each, and Secure whenever | |
| 4 | * the public origin is https. Run for a forge on http and one declared https. | |
| 5 | */ | |
| 6 | import { afterAll, describe, expect, setDefaultTimeout, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 12 | import { signGrant } from "../src/apps/proxy.ts"; | |
| 13 | import { newApp } from "../src/actions/apps.ts"; | |
| 14 | import { createSession } from "../src/core/sessions.ts"; | |
| 15 | import { issueRecovery, markVerified } from "../src/core/verify.ts"; | |
| 16 | import { createUser } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { device } from "./helpers/passkey.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | setDefaultTimeout(60_000); | |
| 23 | ||
| 24 | /** | |
| 25 | * Cookie-setting sites, counted per file (imports included) as `set-cookie` headers and | |
| 26 | * sessionCookie() calls. WALKED files have every site exercised above (by the | |
| 27 | * door named); UNREACHED ones are named with why. The scan below compares | |
| 28 | * these counts to the source exactly, so a new site in a listed file fails | |
| 29 | * until it is walked here or named. | |
| 30 | */ | |
| 31 | const WALKED: Record<string, number> = { | |
| 32 | "src/http/api.ts": 4, // import, password sign-in, sign-out (+1 mention) | |
| 33 | "src/http/server.ts": 1, // the mode switch | |
| 34 | "src/http/rp.ts": 6, // parent sign-in and link: state cookie set and cleared | |
| 35 | "src/apps/proxy.ts": 2, // app grant, app sign-out | |
| 36 | "src/web/pages/passkeys.ts": 4, // import, passkey sign-in, passkey add/recovery-link sign-in (+1 mention) | |
| 37 | "src/web/pages/site.ts": 4, // sessionCookie() itself and its uses: password sign-in/sign-up, logout; the theme cookie | |
| 38 | "src/web/layout.ts": 4, // the flash set on, and cleared from, a redirect | |
| 39 | "src/web/flash.ts": 2, // forge_flash | |
| 40 | }; | |
| 41 | const UNREACHED: Record<string, { sites: number; why: string }> = { | |
| 42 | "src/apps/scaffold.ts": { sites: 7, why: "the `notice` cookie lives in the template generated into new apps, not in the forge's own responses" }, | |
| 43 | "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" }, | |
| 44 | "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" }, | |
| 45 | "src/web/pages/first-admin.ts": { sites: 1, why: "first-admin recovery needs a forge with no admin; same sessionCookie() as the walked doors" }, | |
| 46 | "src/web/pages/signup-passkey.ts": { sites: 2, why: "sign-up by passkey needs an invite; same sessionCookie() as the walked doors" }, | |
| 47 | "src/web/pages/everyday.ts": { sites: 2, why: "the admin/everyday switch needs a paired account; same sessionCookie() as the walked doors" }, | |
| 48 | }; | |
| 49 | ||
| 50 | const dirs: string[] = []; | |
| 51 | const stops: (() => Promise<void> | void)[] = []; | |
| 52 | afterAll(async () => { | |
| 53 | for (const stop of stops) await stop(); | |
| 54 | for (const dir of dirs) await rm(dir, { recursive: true, force: true }); | |
| 55 | }); | |
| 56 | ||
| 57 | const tmp = async () => { | |
| 58 | const dir = await mkdtemp(join(tmpdir(), "forge-cookies-")); | |
| 59 | dirs.push(dir); | |
| 60 | return dir; | |
| 61 | }; | |
| 62 | ||
| 63 | function check(label: string, cookies: string[], https: boolean) { | |
| 64 | expect(cookies.length).toBeGreaterThan(0); | |
| 65 | for (const cookie of cookies) { | |
| 66 | const flags = cookie.split(";").slice(1).map((f) => f.trim().toLowerCase()); | |
| 67 | const where = `${label}: ${cookie.split("=")[0]}`; | |
| 68 | expect([where, flags.includes("httponly")]).toEqual([where, true]); | |
| 69 | expect([where, flags.includes("samesite=lax") || flags.includes("samesite=strict")]).toEqual([where, true]); | |
| 70 | expect([where, flags.includes("secure")]).toEqual([where, https]); | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | for (const https of [false, true]) { | |
| 75 | describe(`cookie flags, forge on ${https ? "https" : "http"}`, () => { | |
| 76 | test("every door sets only HttpOnly, SameSite cookies" + (https ? ", all Secure" : ""), async () => { | |
| 77 | const seen: string[] = []; | |
| 78 | const take = (label: string, res: Response, ...want: string[]) => { | |
| 79 | const set = res.headers.getSetCookie(); | |
| 80 | for (const name of want) expect([label, set.some((c) => c.startsWith(`${name}=`))]).toEqual([label, true]); | |
| 81 | check(label, set, https); | |
| 82 | seen.push(...set); | |
| 83 | return set; | |
| 84 | }; | |
| 85 | const cookieOf = (set: string[], name: string) => set.map((c) => c.split(";")[0]!).find((c) => c.startsWith(`${name}=`))!; | |
| 86 | ||
| 87 | const public_ = https ? "https://forge.cookies.test" : undefined; | |
| 88 | const parent = createApp({ dataDir: await tmp(), port: 0 }); | |
| 89 | const ps: Servers = await serve(parent); | |
| 90 | const child: App = createApp({ dataDir: await tmp(), port: 0, parentUrl: ps.url, parentLoopback: true, ...(public_ ? { url: public_ } : {}) }); | |
| 91 | const cs: Servers = await serve(child); | |
| 92 | stops.push(() => { ps.stop(); cs.stop(); }); | |
| 93 | const origin = public_ ?? cs.url; | |
| 94 | ||
| 95 | const mk = async (app: App, username: string, adminIfFirst = false) => { | |
| 96 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.local`, password: "a-long-family-passphrase", adminIfFirst }); | |
| 97 | if (!made.ok) throw new Error(made.error); | |
| 98 | return { id: made.value.id, user: made.value, cookie: `forge_session=${createSession(app.db, made.value.id).token}` }; | |
| 99 | }; | |
| 100 | const alice = await mk(parent, "alice", true); | |
| 101 | const olive = await mk(child, "olive", true); | |
| 102 | const carol = await mk(child, "carol"); | |
| 103 | const post = (path: string, init: RequestInit & { cookie?: string } = {}) => | |
| 104 | fetch(`${cs.url}${path}`, { method: "POST", redirect: "manual", ...init, headers: { origin, ...(init.cookie ? { cookie: init.cookie } : {}), ...(init.headers as object) } }); | |
| 105 | ||
| 106 | // Password sign-in (the API door) and sign-out. | |
| 107 | const pw = await post("/api/v1/sessions", { headers: { "content-type": "application/json" }, body: JSON.stringify({ username: "carol", password: "a-long-family-passphrase" }) }); | |
| 108 | expect(pw.status).toBe(201); | |
| 109 | take("password sign-in", pw, "forge_session"); | |
| 110 | const out = await post("/logout", { cookie: `forge_session=${createSession(child.db, carol.id).token}` }); | |
| 111 | take("sign-out", out, "forge_session"); | |
| 112 | ||
| 113 | // The mode switch lets go of forge_mode. | |
| 114 | const mode = await fetch(`${cs.url}/?mode=dev`, { redirect: "manual" }); | |
| 115 | expect(mode.status).toBe(303); | |
| 116 | take("mode switch", mode, "forge_mode"); | |
| 117 | ||
| 118 | // The theme choice. | |
| 119 | take("theme", await post("/theme", { cookie: carol.cookie, body: new URLSearchParams({ theme: "dark" }) }), "forge_theme"); | |
| 120 | ||
| 121 | // Passkey: enrol with a session, then sign in without one. | |
| 122 | const d = await device(origin); | |
| 123 | const jsonPost = (path: string, body: unknown, cookie?: string) => | |
| 124 | post(path, { ...(cookie ? { cookie } : {}), headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); | |
| 125 | const opts = (await (await jsonPost("/settings/passkeys/options", {}, carol.cookie)).json()) as { challenge: string }; | |
| 126 | const added = await jsonPost("/settings/passkeys", { name: "k", challenge: opts.challenge, ...(await d.create(opts.challenge)) }, carol.cookie); | |
| 127 | expect(added.status).toBe(200); | |
| 128 | const o = (await (await jsonPost("/signin/passkey/options", {})).json()) as { challenge: string }; | |
| 129 | const pk = await jsonPost("/signin/passkey", { challenge: o.challenge, ...(await d.get(o.challenge)) }); | |
| 130 | expect(pk.status).toBe(200); | |
| 131 | take("passkey sign-in", pk, "forge_session"); | |
| 132 | ||
| 133 | // Recovery link: a new passkey registered from the emailed link signs in. | |
| 134 | markVerified(child.db, carol.id, new Date().toISOString()); | |
| 135 | const token = issueRecovery(child.db, carol.id, Date.now())!; | |
| 136 | const rd = await device(origin); | |
| 137 | const ro = (await (await jsonPost("/recover/options", { token })).json()) as { challenge: string }; | |
| 138 | const recovered = await jsonPost("/recover/passkey", { token, name: "r", challenge: ro.challenge, ...(await rd.create(ro.challenge)) }); | |
| 139 | expect(recovered.status).toBe(200); | |
| 140 | take("recovery-link passkey", recovered, "forge_session"); | |
| 141 | ||
| 142 | // Parent sign-in: link carol to alice at the parent, then sign in through it. | |
| 143 | const consent = async (start: Response) => { | |
| 144 | const to = new URL(start.headers.get("location")!); | |
| 145 | const res = await fetch(`${ps.url}/oauth/authorize`, { | |
| 146 | method: "POST", redirect: "manual", | |
| 147 | headers: { cookie: alice.cookie, origin: ps.url }, | |
| 148 | body: new URLSearchParams({ ...Object.fromEntries(to.searchParams), decision: "allow" }), | |
| 149 | }); | |
| 150 | return new URL(res.headers.get("location")!); | |
| 151 | }; | |
| 152 | const callback = (url: URL, cookie: string) => fetch(`${cs.url}${url.pathname}${url.search}`, { redirect: "manual", headers: { cookie } }); | |
| 153 | const link = await post("/settings/parent/link", { cookie: carol.cookie }); | |
| 154 | expect(link.status).toBe(303); | |
| 155 | const linkState = take("parent link start", link, "forge_rp"); | |
| 156 | const linked = await callback(await consent(link), `${carol.cookie}; ${cookieOf(linkState, "forge_rp")}`); | |
| 157 | take("parent link callback", linked, "forge_rp", "forge_flash"); | |
| 158 | const start = await fetch(`${cs.url}/signin/parent`, { redirect: "manual" }); | |
| 159 | const state = take("parent sign-in start", start, "forge_rp"); | |
| 160 | const done = await callback(await consent(start), cookieOf(state, "forge_rp")); | |
| 161 | expect(done.status).toBe(303); | |
| 162 | const finished = take("parent sign-in", done, "forge_session", "forge_rp"); | |
| 163 | expect(cookieOf(finished, "forge_session").length).toBeGreaterThan("forge_session=".length); | |
| 164 | ||
| 165 | // An app host's grant and its sign-out, through a live apps runtime. | |
| 166 | const runtime = new AppsRuntime(child, { domain: "apps.test", scheme: https ? "https" : "http", publicPort: cs.port, forgeUrl: cs.url, concurrency: 1 }); | |
| 167 | await runtime.start(); | |
| 168 | stops.push(() => runtime.stop()); | |
| 169 | const made = await newApp(child, olive.user, { name: "cookiegrant", visibility: "private" }); | |
| 170 | if (!made.ok) throw new Error(made.error); | |
| 171 | const target = { app: made.value.app, env: "production" as const }; | |
| 172 | const via = (path: string) => | |
| 173 | fetch(`${cs.url}${path}`, { redirect: "manual", headers: { host: "cookiegrant.apps.test", ...(https ? { "x-forwarded-proto": "https" } : {}) } }); | |
| 174 | const grant = await via(`/__forge/grant?t=${encodeURIComponent(signGrant(child.config.dataDir, target, 60_000))}&to=/`); | |
| 175 | expect(grant.status).toBe(303); | |
| 176 | take("app grant", grant); | |
| 177 | take("app sign-out", await via("/__forge/signout")); | |
| 178 | ||
| 179 | expect(new Set(seen.map((c) => c.split("=")[0])).size).toBeGreaterThanOrEqual(5); | |
| 180 | }); | |
| 181 | }); | |
| 182 | } | |
| 183 | ||
| 184 | describe("cookie-setting sites", () => { | |
| 185 | test("every file's cookie sites are walked above or named, counted exactly", async () => { | |
| 186 | const found: Record<string, number> = {}; | |
| 187 | for await (const file of new Bun.Glob("src/**/*.ts").scan(".")) { | |
| 188 | const text = await Bun.file(file).text(); | |
| 189 | const n = (text.match(/["']set-cookie["']|Set-Cookie|sessionCookie\(/gi) ?? []).length; | |
| 190 | if (n > 0) found[file] = n; | |
| 191 | } | |
| 192 | const expected: Record<string, number> = { ...WALKED }; | |
| 193 | for (const [file, { sites }] of Object.entries(UNREACHED)) expected[file] = sites; | |
| 194 | expect(found).toEqual(expected); | |
| 195 | for (const { why } of Object.values(UNREACHED)) expect(why.length).toBeGreaterThan(10); | |
| 196 | }); | |
| 197 | }); |
new above
offline
Low (maintenance, not security): this regex also counts comments and prose. layout.ts:691 and flash.ts:29/36 are doc comments, and scaffold.ts includes its own template test lines. So any doc comment anywhere in src/ that mentions "Set-Cookie" will turn this test red with no cookie having changed. When it next bites, consider matching only code sites, for example
headers.append("set-cookie","set-cookie":andsessionCookie(outside its definition, or skip lines starting with*or//. Not blocking.