Public: anyone can read this; the crew can change it.
Every cookie the forge sets is HttpOnly, SameSite and Secure behind TLS, and a test lists them all #12340
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12314-every-cookie-the-forge-sets-is-httponly → main 7 commits fork
15 files
changed +200
−115
src/apps/feedback.ts +2 −1
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | * keyed on the socket's client (`http/peer.ts`), never on a header the |
| 16 | 16 | * client wrote. |
| 17 | 17 | */ |
| 18 | import { setCookie } from "../auth/cookies.ts"; | |
| 18 | 19 | import { randomBytes } from "node:crypto"; |
| 19 | 20 | import type { App } from "../app.ts"; |
| 20 | 21 | import { notesFor, sendNote, type Sender } from "../actions/app_notes.ts"; |
| @@ -142,7 +143,7 @@ function senderOf(ask: FeedbackAsk, door: Door): { sender: Sender; fresh?: strin | ||
| 142 | 143 | } |
| 143 | 144 | |
| 144 | 145 | const visitorCookie = (value: string, secure: boolean): string => |
| 145 | `${VISITOR_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${secure ? "; Secure" : ""}`; | |
| 146 | setCookie(VISITOR_COOKIE, value, { maxAge: 31536000, secure }); | |
| 146 | 147 | |
| 147 | 148 | /** The body as JSON, read no further than `REQUEST_MAX` bytes whatever it declares. */ |
| 148 | 149 | async function readJson(request: Request): Promise<Record<string, unknown> | undefined> { |
src/apps/proxy.ts +3 −2
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | * directory — so guessing a hostname yields the forge's own "not found". |
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | import { expireCookie, setCookie } from "../auth/cookies.ts"; | |
| 18 | 19 | import { compressBytes, MIN_BYTES, negotiate } from "../http/compress.ts"; |
| 19 | 20 | import type { Database } from "bun:sqlite"; |
| 20 | 21 | import { getIssue, type Issue } from "../core/issues.ts"; |
| @@ -1062,7 +1063,7 @@ export async function proxyApp( | ||
| 1062 | 1063 | status: 303, |
| 1063 | 1064 | headers: { |
| 1064 | 1065 | location: safePath(url.searchParams.get("to")), |
| 1065 | "set-cookie": `${grantCookieName(target)}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure ? "; Secure" : ""}`, | |
| 1066 | "set-cookie": expireCookie(grantCookieName(target), { secure }), | |
| 1066 | 1067 | "cache-control": "no-store", |
| 1067 | 1068 | }, |
| 1068 | 1069 | }), |
| @@ -1814,7 +1815,7 @@ function grant( | ||
| 1814 | 1815 | status: 303, |
| 1815 | 1816 | headers: { |
| 1816 | 1817 | location: path, |
| 1817 | "set-cookie": `${grantCookieName(target)}=${cookie}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(GRANT_COOKIE_TTL_MS / 1000)}${secure ? "; Secure" : ""}`, | |
| 1818 | "set-cookie": setCookie(grantCookieName(target), cookie, { maxAge: Math.floor(GRANT_COOKIE_TTL_MS / 1000), secure }), | |
| 1818 | 1819 | "cache-control": "no-store", |
| 1819 | 1820 | }, |
| 1820 | 1821 | }); |
src/auth/cookies.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | /** | |
| 2 | * The one place a cookie line is spelled (#12314). Every cookie the forge | |
| 3 | * sets is HttpOnly and SameSite=Lax, and Secure when `secure` says the | |
| 4 | * request or the forge's own address is https (see `secureFor` in | |
| 5 | * src/http/cookie_secure.ts). Nothing here can switch a flag off: a cookie | |
| 6 | * that needs to drop one has to change this file, and test/cookie-flags.test.ts | |
| 7 | * walks src/ for any line that spells a cookie anywhere else. | |
| 8 | * | |
| 9 | * Readable by script: none. The theme is painted by the server from its | |
| 10 | * cookie on the first byte, so no page script reads any cookie; were one ever | |
| 11 | * to need it, it is named here, with its reason, and nowhere else. | |
| 12 | * | |
| 13 | * Lax, not Strict: a person sent back from a parent forge's sign-in or an | |
| 14 | * app's sign-in handoff (#12177) arrives by a top-level cross-site | |
| 15 | * navigation and must carry their session and state cookies on it. | |
| 16 | */ | |
| 17 | ||
| 18 | export interface CookieOptions { | |
| 19 | /** Where the browser sends it back; `/` unless the cookie is read only below a narrower path. */ | |
| 20 | path?: string | undefined; | |
| 21 | /** Seconds it lives; whole, not negative. */ | |
| 22 | maxAge?: number; | |
| 23 | /** When it ends, for a cookie whose end is a moment (the session). */ | |
| 24 | expires?: Date; | |
| 25 | /** `secureFor(url, forgeUrl)`: the request or the forge is https. */ | |
| 26 | secure: boolean; | |
| 27 | } | |
| 28 | ||
| 29 | /** A cookie name: an RFC 6265 token, which every forge name is a subset of. */ | |
| 30 | const NAME = /^[A-Za-z0-9_-]{1,64}$/; | |
| 31 | /** A cookie value: no separators, quotes, spaces or controls (RFC 6265 cookie-octet). */ | |
| 32 | const VALUE = /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*$/; | |
| 33 | /** A path: absolute, and nothing that would end the attribute. */ | |
| 34 | const PATH = /^\/[A-Za-z0-9/_.-]*$/; | |
| 35 | ||
| 36 | /** | |
| 37 | * The `Set-Cookie` value for `name=value`, every flag on. | |
| 38 | * Throws on a name, value or path that would break the line. | |
| 39 | */ | |
| 40 | export function setCookie(name: string, value: string, o: CookieOptions): string { | |
| 41 | if (!NAME.test(name)) throw new Error(`cookie name ${JSON.stringify(name)} is not a token`); | |
| 42 | if (!VALUE.test(value)) throw new Error(`cookie ${name}'s value holds a character a cookie cannot carry`); | |
| 43 | const path = o.path ?? "/"; | |
| 44 | if (!PATH.test(path)) throw new Error(`cookie ${name}'s path ${JSON.stringify(path)} is not an absolute path`); | |
| 45 | const parts = [`${name}=${value}`, `Path=${path}`, "HttpOnly", "SameSite=Lax"]; | |
| 46 | if (o.maxAge !== undefined) { | |
| 47 | if (!Number.isInteger(o.maxAge) || o.maxAge < 0) throw new Error(`cookie ${name}'s max-age ${o.maxAge} is not a whole number of seconds`); | |
| 48 | parts.push(`Max-Age=${o.maxAge}`); | |
| 49 | } | |
| 50 | if (o.expires !== undefined) parts.push(`Expires=${o.expires.toUTCString()}`); | |
| 51 | if (o.secure) parts.push("Secure"); | |
| 52 | return parts.join("; "); | |
| 53 | } | |
| 54 | ||
| 55 | /** The `Set-Cookie` value that ends `name` now, on the same path it was set on. */ | |
| 56 | export function expireCookie(name: string, o: { path?: string; secure: boolean }): string { | |
| 57 | return setCookie(name, "", { path: o.path, maxAge: 0, secure: o.secure }); | |
| 58 | } |
src/http/rp.ts +3 −4
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | * Both are GETs that change nothing a stranger could use: a callback with |
| 12 | 12 | * no matching cookie and state is refused before anything is fetched. |
| 13 | 13 | */ |
| 14 | import { setCookie } from "../auth/cookies.ts"; | |
| 14 | 15 | import { secureFor } from "./cookie_secure.ts"; |
| 15 | 16 | import { createHash, randomBytes } from "node:crypto"; |
| 16 | 17 | import type { App } from "../app.ts"; |
| @@ -122,10 +123,8 @@ function failure(ctx: Ctx, message: string, status: number): Response { | ||
| 122 | 123 | return response; |
| 123 | 124 | } |
| 124 | 125 | |
| 125 | export const cookieHeader = (value: string, maxAge: number, url: URL, forgeUrl?: string): string => { | |
| 126 | const secure = secureFor(url, forgeUrl) ? "; Secure" : ""; | |
| 127 | return `${STATE_COOKIE}=${value}; Path=/signin/parent; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`; | |
| 128 | }; | |
| 126 | export const cookieHeader = (value: string, maxAge: number, url: URL, forgeUrl?: string): string => | |
| 127 | setCookie(STATE_COOKIE, value, { path: "/signin/parent", maxAge, secure: secureFor(url, forgeUrl) }); | |
| 129 | 128 | |
| 130 | 129 | export const parentStart = (ctx: Ctx): Promise<Response> => begin(ctx); |
| 131 | 130 |
src/http/server.ts +2 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * verdict. Auth is resolved once per request and handed to handlers as data. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { expireCookie } from "../auth/cookies.ts"; | |
| 8 | 9 | import { checkText } from "./check_text.ts"; |
| 9 | 10 | import { appSecretGrant, appSecretRevoke } from "../web/pages/app_secrets.ts"; |
| 10 | 11 | import { apiRoot } from "./api_root.ts"; |
| @@ -2004,12 +2005,11 @@ function withoutMode(url: URL, request: Request, forgeUrl?: string): Response | | ||
| 2004 | 2005 | if (request.method !== "GET" || !url.searchParams.has("mode")) return undefined; |
| 2005 | 2006 | const clean = new URL(url); |
| 2006 | 2007 | clean.searchParams.delete("mode"); |
| 2007 | const secure = secureFor(url, forgeUrl) ? "; Secure" : ""; | |
| 2008 | 2008 | return new Response(null, { |
| 2009 | 2009 | status: 303, |
| 2010 | 2010 | headers: { |
| 2011 | 2011 | location: `${clean.pathname}${clean.search}${clean.hash}`, |
| 2012 | "set-cookie": `forge_mode=; Max-Age=0; Path=/; SameSite=Lax; HttpOnly${secure}`, | |
| 2012 | "set-cookie": expireCookie("forge_mode", { secure: secureFor(url, forgeUrl) }), | |
| 2013 | 2013 | "cache-control": "no-store", |
| 2014 | 2014 | }, |
| 2015 | 2015 | }); |
src/perf/visitor_id.ts +2 −1
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | * client: no canvas, fonts, audio or WebGL. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import { setCookie } from "../auth/cookies.ts"; | |
| 14 | 15 | import { createHmac, timingSafeEqual } from "node:crypto"; |
| 15 | 16 | import { sha256 } from "../kernel/hash.ts"; |
| 16 | 17 | import { addressBytes } from "../kernel/cidr.ts"; |
| @@ -102,7 +103,7 @@ export function probableId(salt: string, headers: Headers, address: string): str | ||
| 102 | 103 | |
| 103 | 104 | /** The Set-Cookie line for a new id: HttpOnly, Secure when the forge is https, SameSite=Lax, 13 months, this host only (no Domain). */ |
| 104 | 105 | export const cookieLine = (value: string, secure: boolean): string => |
| 105 | `${COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE}${secure ? "; Secure" : ""}`; | |
| 106 | setCookie(COOKIE, value, { maxAge: COOKIE_MAX_AGE, secure }); | |
| 106 | 107 | |
| 107 | 108 | /** How far a visitor got; the highest step seen is kept. */ |
| 108 | 109 | export const STEPS = ["none", "saw plans", "clicked a plan", "started a forge", "paid"] as const; |
src/spawn/origin_broker.ts +3 −3
| @@ -37,6 +37,7 @@ | ||
| 37 | 37 | * provider. All a child could do with the call is refresh what it already holds. |
| 38 | 38 | */ |
| 39 | 39 | |
| 40 | import { expireCookie, setCookie } from "../auth/cookies.ts"; | |
| 40 | 41 | import { timingSafeEqual } from "node:crypto"; |
| 41 | 42 | import type { App } from "../app.ts"; |
| 42 | 43 | import { childNamed, beatKeyOf, liveChildUrl } from "../actions/spawn.ts"; |
| @@ -336,14 +337,13 @@ export async function brokerStart( | ||
| 336 | 337 | redirectUri, |
| 337 | 338 | scope: scopesFor(found.value.kind, "oauth", software), |
| 338 | 339 | }); |
| 339 | const secure = secureFor(ctx.url, app.config.url) ? "; Secure" : ""; | |
| 340 | 340 | const response = brokerAskPage(ctx, { |
| 341 | 341 | child: asking.name, |
| 342 | 342 | childUrl: liveChildUrl(app, asking.name), |
| 343 | 343 | host: offer.host, |
| 344 | 344 | go: url, |
| 345 | 345 | }); |
| 346 | response.headers.append("set-cookie", `${BROKER_COOKIE}=${cookie}; Path=/settings/origins; HttpOnly; SameSite=Lax; Max-Age=${CONSENT_TTL_MS / 1000}${secure}`); | |
| 346 | response.headers.append("set-cookie", setCookie(BROKER_COOKIE, cookie, { path: "/settings/origins", maxAge: Math.floor(CONSENT_TTL_MS / 1000), secure: secureFor(ctx.url, app.config.url) })); | |
| 347 | 347 | return response; |
| 348 | 348 | } |
| 349 | 349 | |
| @@ -505,7 +505,7 @@ export async function brokerCallback( | ||
| 505 | 505 | } |
| 506 | 506 | |
| 507 | 507 | const clearCookie = (ctx: Ctx): string => |
| 508 | `${BROKER_COOKIE}=; Path=/settings/origins; HttpOnly; SameSite=Lax; Max-Age=0${secureFor(ctx.url, ctx.app.config.url) ? "; Secure" : ""}`; | |
| 508 | expireCookie(BROKER_COOKIE, { path: "/settings/origins", secure: secureFor(ctx.url, ctx.app.config.url) }); | |
| 509 | 509 | |
| 510 | 510 | // ---- the child's signed calls ---- |
| 511 | 511 |
src/web/flash.ts +3 −4
| @@ -1,3 +1,4 @@ | ||
| 1 | import { expireCookie, setCookie } from "../auth/cookies.ts"; | |
| 1 | 2 | import { secureFor } from "../http/cookie_secure.ts"; |
| 2 | 3 | |
| 3 | 4 | /** |
| @@ -31,14 +32,12 @@ const MAX_FLASH = 2000; | ||
| 31 | 32 | /** The `Set-Cookie` value that carries a flash to the next request. */ |
| 32 | 33 | export function flashCookie(flash: Flash, url?: URL, forgeUrl?: string): string { |
| 33 | 34 | const encoded = encodeURIComponent(JSON.stringify(flash)).slice(0, MAX_FLASH); |
| 34 | const secure = secureFor(url, forgeUrl) ? "; Secure" : ""; | |
| 35 | return `${FLASH_COOKIE}=${encoded}; Path=/; Max-Age=60; SameSite=Lax; HttpOnly${secure}`; | |
| 35 | return setCookie(FLASH_COOKIE, encoded, { maxAge: 60, secure: secureFor(url, forgeUrl) }); | |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | 38 | /** The `Set-Cookie` value that clears it, sent with the page that showed it. */ |
| 39 | 39 | export function clearFlashCookie(url?: URL, forgeUrl?: string): string { |
| 40 | const secure = secureFor(url, forgeUrl) ? "; Secure" : ""; | |
| 41 | return `${FLASH_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly${secure}`; | |
| 40 | return expireCookie(FLASH_COOKIE, { secure: secureFor(url, forgeUrl) }); | |
| 42 | 41 | } |
| 43 | 42 | |
| 44 | 43 | /** |
src/web/gift_cookie.ts +3 −4
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * the history never hold it. The new-forge page reads it from here. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { expireCookie, setCookie } from "../auth/cookies.ts"; | |
| 7 | 8 | import { cookieValue } from "../http/context.ts"; |
| 8 | 9 | import { secureFor } from "../http/cookie_secure.ts"; |
| 9 | 10 | |
| @@ -12,9 +13,8 @@ const SHAPE = /^[A-Za-z0-9-]{1,64}$/; | ||
| 12 | 13 | |
| 13 | 14 | /** A `303` to `to` that keeps the code in a cookie, not in the address. */ |
| 14 | 15 | export function keepGift(code: string, to: string, url: URL, forgeUrl?: string): Response { |
| 15 | const secure = secureFor(url, forgeUrl) ? "; Secure" : ""; | |
| 16 | 16 | const headers = new Headers({ location: to, "cache-control": "no-store" }); |
| 17 | if (SHAPE.test(code)) headers.append("set-cookie", `${NAME}=${code}; Path=/; Max-Age=3600; SameSite=Lax; HttpOnly${secure}`); | |
| 17 | if (SHAPE.test(code)) headers.append("set-cookie", setCookie(NAME, code, { maxAge: 3600, secure: secureFor(url, forgeUrl) })); | |
| 18 | 18 | return new Response(null, { status: 303, headers }); |
| 19 | 19 | } |
| 20 | 20 | |
| @@ -26,6 +26,5 @@ export function keptGift(request: Request): string { | ||
| 26 | 26 | |
| 27 | 27 | /** Sent once the forge is made: the code is spent from the browser too. */ |
| 28 | 28 | export function dropGift(url: URL, forgeUrl?: string): string { |
| 29 | const secure = secureFor(url, forgeUrl) ? "; Secure" : ""; | |
| 30 | return `${NAME}=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly${secure}`; | |
| 29 | return expireCookie(NAME, { secure: secureFor(url, forgeUrl) }); | |
| 31 | 30 | } |
src/web/pages/home_flow.ts +2 −2
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | * version without being drawn. The page is a static picture with scripts off. |
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | import { setCookie } from "../../auth/cookies.ts"; | |
| 18 | 19 | import type { App } from "../../app.ts"; |
| 19 | 20 | import { listChildren } from "../../actions/spawn.ts"; |
| 20 | 21 | import { lastHeard, MAX_CHILDREN, QUIET_MS } from "../../spawn/heartbeat.ts"; |
| @@ -545,8 +546,7 @@ export async function homeFlowPage(ctx: Ctx, known?: { readonly spawned: Awaited | ||
| 545 | 546 | const res = page({ ctx, title: "Home", body, wide: true, scriptJs: `${liveRegionsJs}\n${homeFlowJs}` }); |
| 546 | 547 | // The view asked for in the address is remembered for the next visit. |
| 547 | 548 | if (asked) { |
| 548 | const secure = secureFor(ctx.url, ctx.app.config.url) ? "; Secure" : ""; | |
| 549 | res.headers.append("set-cookie", `${VIEW_COOKIE}=${view}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly${secure}`); | |
| 549 | res.headers.append("set-cookie", setCookie(VIEW_COOKIE, view, { maxAge: 31536000, secure: secureFor(ctx.url, ctx.app.config.url) })); | |
| 550 | 550 | } |
| 551 | 551 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 552 | 552 | return res; |
src/web/pages/map.ts +2 −1
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | * can see, one lane each. |
| 20 | 20 | */ |
| 21 | 21 | |
| 22 | import { setCookie } from "../../auth/cookies.ts"; | |
| 22 | 23 | import { mayDirectCrew } from "../../crew/direct.ts"; |
| 23 | 24 | import { faceAt } from "../pixel.ts"; |
| 24 | 25 | import type { MapHold } from "../../crew/held.ts"; |
| @@ -1466,7 +1467,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1466 | 1467 | if (seen.cookie !== undefined) |
| 1467 | 1468 | response.headers.append( |
| 1468 | 1469 | "set-cookie", |
| 1469 | `${name}=${seen.cookie}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly${secureFor(ctx.url, ctx.app.config.url) ? "; Secure" : ""}`, | |
| 1470 | setCookie(name, seen.cookie, { maxAge: 31536000, secure: secureFor(ctx.url, ctx.app.config.url) }), | |
| 1470 | 1471 | ); |
| 1471 | 1472 | return response; |
| 1472 | 1473 | } |
src/web/pages/pricing.ts +2 −1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * say "ask the operator". The rules are in `actions/order.ts`. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { setCookie } from "../../auth/cookies.ts"; | |
| 9 | 10 | import { deliverLink, mayView, NO_PROVIDER, placeOrder, reconcileOrder, resendLink, type Refusal } from "../../actions/order.ts"; |
| 10 | 11 | import { checkoutOf } from "../../billing/checkout.ts"; |
| 11 | 12 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| @@ -103,7 +104,7 @@ export function pricingPage(ctx: Ctx): Response { | ||
| 103 | 104 | } |
| 104 | 105 | |
| 105 | 106 | const cookieFor = (ctx: Ctx, value: string, maxAge: number): string => |
| 106 | `${COOKIE}=${value}; Path=/pricing; Max-Age=${maxAge}; SameSite=Lax; HttpOnly${secureFor(ctx.url, ctx.app.config.url) ? "; Secure" : ""}`; | |
| 107 | setCookie(COOKIE, value, { path: "/pricing", maxAge, secure: secureFor(ctx.url, ctx.app.config.url) }); | |
| 107 | 108 | |
| 108 | 109 | // POST /pricing/order — free goes on to the free path; a paid plan holds the name and goes to checkout. |
| 109 | 110 | export async function pricingOrder(ctx: Ctx): Promise<Response> { |
src/web/pages/site.ts +5 −5
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * typed still in it. A dead end that loses input is a bug, not a message. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { expireCookie, setCookie } from "../../auth/cookies.ts"; | |
| 11 | 12 | import { safeNext, withReturn } from "../return_to.ts"; |
| 12 | 13 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 13 | 14 | import { keepGift } from "../gift_cookie.ts"; |
| @@ -79,8 +80,7 @@ export function sessionCookie( | ||
| 79 | 80 | ): string { |
| 80 | 81 | // Secure when the request or the forge's own address is https: behind a |
| 81 | 82 | // proxy that ends TLS the request itself arrives as http. |
| 82 | const secure = secureFor(url, forgeUrl) ? "; Secure" : ""; | |
| 83 | return `forge_session=${value}; Path=/; HttpOnly; SameSite=Lax; Expires=${expires.toUTCString()}${secure}`; | |
| 83 | return setCookie("forge_session", value, { expires, secure: secureFor(url, forgeUrl) }); | |
| 84 | 84 | } |
| 85 | 85 | |
| 86 | 86 | export { safeNext }; |
| @@ -784,10 +784,10 @@ export async function themeSubmit(ctx: Ctx): Promise<Response> { | ||
| 784 | 784 | const form = await readForm(ctx, undefined, STRANGER_MAX); |
| 785 | 785 | if (form instanceof Response) return form; |
| 786 | 786 | const choice = field(form, "theme"); |
| 787 | const secure = secureFor(ctx.url, ctx.app.config.url) ? "; Secure" : ""; | |
| 787 | const secure = secureFor(ctx.url, ctx.app.config.url); | |
| 788 | 788 | const cookie = isThemeName(choice) |
| 789 | ? `forge_theme=${choice}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly${secure}` | |
| 790 | : `forge_theme=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly${secure}`; | |
| 789 | ? setCookie("forge_theme", choice, { maxAge: 31536000, secure }) | |
| 790 | : expireCookie("forge_theme", { secure }); | |
| 791 | 791 | |
| 792 | 792 | // Return to the page the form was submitted from — same-origin paths only, |
| 793 | 793 | // so the redirect can never be steered off this forge. |
test/cookie-flags.test.ts +109 −84
| @@ -2,7 +2,9 @@ | ||
| 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 | * The walk at the end checks every cookie set in src/, reached or not (#10968): | |
| 6 | * one helper, src/auth/cookies.ts, spells them all, and a cookie spelled by | |
| 7 | * hand anywhere else fails here (#12314). | |
| 6 | 8 | * Each cookie is scoped to `Path=/` unless COOKIES names a narrower path and |
| 7 | 9 | * why, and a new place that writes one fails here with its file and line (#11864). |
| 8 | 10 | */ |
| @@ -34,9 +36,9 @@ quietLogs(); | ||
| 34 | 36 | setDefaultTimeout(60_000); |
| 35 | 37 | |
| 36 | 38 | /** |
| 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 | |
| 39 | * Every cookie set in src/ through src/auth/cookies.ts (`setCookie(name, …)` | |
| 40 | * or `expireCookie(name, …)`), keyed by its file and its name as the call | |
| 41 | * spells it, with how many calls set it there. `name` is what a browser sees (a RegExp when the name carries | |
| 40 | 42 | * the thing it is for); `door` is the walk above that drives it, or `why` says |
| 41 | 43 | * why no walk does. `path` is set only where a cookie is narrower than `/`, |
| 42 | 44 | * with its reason: a narrower path is stricter, never weaker. |
| @@ -51,31 +53,31 @@ interface Spelled { | ||
| 51 | 53 | const COOKIES: Record<string, Spelled> = { |
| 52 | 54 | "src/web/pages/site.ts forge_session": { name: "forge_session", lines: 1, door: "password sign-in" }, |
| 53 | 55 | "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" }, | |
| 56 | "src/web/flash.ts FLASH_COOKIE": { name: "forge_flash", lines: 2, door: "parent link callback" }, | |
| 55 | 57 | "src/http/server.ts forge_mode": { name: "forge_mode", lines: 1, door: "mode switch" }, |
| 56 | "src/http/rp.ts ${STATE_COOKIE}": { | |
| 58 | "src/http/rp.ts STATE_COOKIE": { | |
| 57 | 59 | name: "forge_rp", |
| 58 | 60 | lines: 1, |
| 59 | 61 | door: "parent sign-in start", |
| 60 | 62 | path: { path: "/signin/parent", why: "the OpenID state is read only by /signin/parent/callback; no other page should carry it" }, |
| 61 | 63 | }, |
| 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/home_flow.ts ${VIEW_COOKIE}": { | |
| 64 | "src/apps/proxy.ts grantCookieName(target)": { name: /^forge_grant_[0-9a-z]{8}$/, lines: 2, door: "app grant" }, | |
| 65 | "src/apps/feedback.ts VISITOR_COOKIE": { name: "forge_visitor", lines: 1, door: "app note" }, | |
| 66 | "src/perf/visitor_id.ts COOKIE": { name: "forge_visitor", lines: 1, door: "page-view beacon" }, | |
| 67 | "src/web/gift_cookie.ts NAME": { name: "forge_gift", lines: 2, door: "gift link" }, | |
| 68 | "src/web/pages/map.ts name": { name: /^rm_[\w-]+$/, lines: 1, door: "roadmap" }, | |
| 69 | "src/web/pages/home_flow.ts VIEW_COOKIE": { | |
| 68 | 70 | name: "forge_home_view", |
| 69 | 71 | lines: 1, |
| 70 | 72 | why: "set when the home is asked for with a view in the address; HttpOnly, Lax, Secure over https, checked in the source walk", |
| 71 | 73 | }, |
| 72 | "src/spawn/origin_broker.ts ${BROKER_COOKIE}": { | |
| 74 | "src/spawn/origin_broker.ts BROKER_COOKIE": { | |
| 73 | 75 | name: "forge_broker", |
| 74 | 76 | lines: 2, |
| 75 | 77 | why: "the one-click connect needs a parent's OAuth application to answer; HttpOnly, Lax, Secure over https, checked in the source walk", |
| 76 | 78 | path: { path: "/settings/origins", why: "the connect ticket is read only by /settings/origins pages; no other page should carry it" }, |
| 77 | 79 | }, |
| 78 | "src/web/pages/pricing.ts ${COOKIE}": { | |
| 80 | "src/web/pages/pricing.ts COOKIE": { | |
| 79 | 81 | name: "forge_order", |
| 80 | 82 | lines: 1, |
| 81 | 83 | why: "a paid order needs a payment provider to answer placeOrder; HttpOnly, Lax, Secure over https, checked in the source walk", |
| @@ -95,6 +97,7 @@ const entryFor = (name: string): Spelled | undefined => | ||
| 95 | 97 | * differs, so a new site fails until it is walked here or named. |
| 96 | 98 | */ |
| 97 | 99 | const WALKED: Record<string, number> = { |
| 100 | "src/auth/cookies.ts": 2, // the doc comments of setCookie and expireCookie, which every builder calls | |
| 98 | 101 | "src/http/api/tokens.ts": 4, // password sign-in, sign-out: a set-cookie header and a sessionCookie() call each |
| 99 | 102 | "src/http/server.ts": 1, // the mode switch |
| 100 | 103 | "src/http/rp.ts": 6, // parent sign-in and link: state cookie set and cleared |
| @@ -149,8 +152,8 @@ function check(label: string, cookies: string[], https: boolean) { | ||
| 149 | 152 | const where = `${label}: ${name}`; |
| 150 | 153 | const entry = entryFor(name); |
| 151 | 154 | expect([where, entry === undefined ? "a cookie COOKIES does not name" : "named"]).toEqual([where, "named"]); |
| 152 | // A cookie a page script must read goes on EXEMPT with its reason; none does today. | |
| 153 | if (EXEMPT[name] === undefined) expect([where, flags.includes("httponly")]).toEqual([where, true]); | |
| 155 | // No cookie is readable by script: src/auth/cookies.ts has no way to drop HttpOnly. | |
| 156 | expect([where, flags.includes("httponly")]).toEqual([where, true]); | |
| 154 | 157 | expect([where, flags.includes("samesite=lax") || flags.includes("samesite=strict")]).toEqual([where, true]); |
| 155 | 158 | expect([where, flags.includes("secure")]).toEqual([where, https]); |
| 156 | 159 | // Exactly one Path, and it is `/` unless the entry scopes it narrower. |
| @@ -246,7 +249,7 @@ for (const https of [false, true]) { | ||
| 246 | 249 | }; |
| 247 | 250 | const callback = (url: URL, cookie: string) => { |
| 248 | 251 | // The state cookie's narrower Path must still reach the callback that reads it. |
| 249 | expect(url.pathname.startsWith(COOKIES["src/http/rp.ts ${STATE_COOKIE}"]!.path!.path)).toBe(true); | |
| 252 | expect(url.pathname.startsWith(COOKIES["src/http/rp.ts STATE_COOKIE"]!.path!.path)).toBe(true); | |
| 250 | 253 | return fetch(`${cs.url}${url.pathname}${url.search}`, { redirect: "manual", headers: { cookie } }); |
| 251 | 254 | }; |
| 252 | 255 | const link = await post("/settings/parent/link", { cookie: carol.cookie }); |
| @@ -382,11 +385,9 @@ describe("cookie-setting sites", () => { | ||
| 382 | 385 | const SITE = /["']set-cookie["']|Set-Cookie|sessionCookie\(/gi; |
| 383 | 386 | const found: Record<string, string[]> = {}; |
| 384 | 387 | for await (const file of new Bun.Glob("src/**/*.ts").scan(".")) { |
| 385 | const lines = (await Bun.file(file).text()).split("\n"); | |
| 386 | lines.forEach((line, i) => { | |
| 387 | const at = `${file}:${i + 1}`; | |
| 388 | (found[file] ??= []).push(...[...line.matchAll(SITE)].map(() => at)); | |
| 389 | }); | |
| 388 | // One pass over the file's text; a line number is counted only for a hit. | |
| 389 | const text = await Bun.file(file).text(); | |
| 390 | found[file] = [...text.matchAll(SITE)].map((m) => `${file}:${text.slice(0, m.index).split("\n").length}`); | |
| 390 | 391 | } |
| 391 | 392 | const expected: Record<string, number> = { ...WALKED }; |
| 392 | 393 | for (const [file, { sites }] of Object.entries(UNREACHED)) expected[file] = sites; |
| @@ -420,27 +421,21 @@ describe("cookie-setting sites", () => { | ||
| 420 | 421 | |
| 421 | 422 | /** |
| 422 | 423 | * The doors above prove the cookies a request can reach; this walk proves the |
| 423 | * rest (#10968). Every cookie spelled in src/ (`name=value; Attribute`, outside | |
| 424 | * comments) says HttpOnly, SameSite=Lax|Strict and Secure (literal, or | |
| 425 | * conditioned on `secureFor`'s https rule), unless its name is on EXEMPT. | |
| 424 | * rest (#10968, #12314). One helper, src/auth/cookies.ts, spells every cookie | |
| 425 | * line and turns every flag on; no other line in src/ may spell one, and every | |
| 426 | * call to it is named in COOKIES, scoped to the path COOKIES says, with its | |
| 427 | * `secure` taken from the https rule rather than a literal. | |
| 426 | 428 | */ |
| 427 | 429 | |
| 428 | /** | |
| 429 | * Cookies allowed to go without a flag, by name as the source spells it, one | |
| 430 | * line of reason each. Empty today: every cookie has every flag. A cookie a | |
| 431 | * first-party script must read (a theme painted before the server answers, | |
| 432 | * say) would go here; the session and any credential cookie never do. | |
| 433 | */ | |
| 434 | const EXEMPT: Record<string, string> = {}; | |
| 435 | ||
| 436 | /** Names that carry a credential: never exempt, whatever the list says. */ | |
| 437 | const NEVER_EXEMPT = /session|token|grant|state|visitor/i; | |
| 430 | const HELPER = "src/auth/cookies.ts"; | |
| 438 | 431 | |
| 439 | 432 | /** |
| 440 | * What a `set-cookie` writer may pass when it is not an inline literal: a | |
| 441 | * builder whose own literal the walk checks, or a variable holding one. | |
| 433 | * What a `set-cookie` writer may pass: the helper itself, or a builder (or a | |
| 434 | * variable holding a builder's line) that calls it. | |
| 442 | 435 | */ |
| 443 | 436 | const BUILDERS: Record<string, string> = { |
| 437 | "setCookie(": HELPER, | |
| 438 | "expireCookie(": HELPER, | |
| 444 | 439 | "sessionCookie(": "src/web/pages/site.ts", |
| 445 | 440 | "flashCookie(": "src/web/flash.ts", |
| 446 | 441 | "clearFlashCookie(": "src/web/flash.ts", |
| @@ -451,67 +446,101 @@ const BUILDERS: Record<string, string> = { | ||
| 451 | 446 | cookie: "src/http/measure.ts, the line `trackVisitor` returns, built by `cookieLine` in src/perf/visitor_id.ts", |
| 452 | 447 | "clearCookie(": "src/spawn/origin_broker.ts, the connect ticket cleared with Max-Age 0", |
| 453 | 448 | "dropGift(": "src/web/gift_cookie.ts, the gift cookie cleared with Max-Age 0", |
| 454 | "o.cookie": "src/web/layout.ts, a page's cookie (the theme literal in src/web/pages/site.ts)", | |
| 449 | "o.cookie": "src/web/layout.ts, a page's cookie (the theme, from setCookie in src/web/pages/site.ts)", | |
| 455 | 450 | }; |
| 456 | 451 | |
| 457 | /** `name=value; Attribute`: a cookie spelled out, its name literal or `${…}`. */ | |
| 452 | /** `name=value; Attribute`: a cookie spelled out by hand, its name literal or `${…}`. */ | |
| 458 | 453 | const SPELLED = /(\$\{[^}]+\}|[A-Za-z_][\w-]*)=[^;\n]*?;\s*(?:Path|Max-Age|Expires|Domain|HttpOnly|SameSite|Secure)\b/; |
| 459 | 454 | |
| 460 | /** The flags a cookie line lacks; empty when it has them all. `path` is the Path it must name. */ | |
| 461 | function lacking(line: string, path = "/"): string[] { | |
| 462 | const lacks: string[] = []; | |
| 463 | const paths = [...line.matchAll(/\bPath=([^;\s`"']*)/g)].map((m) => m[1]); | |
| 464 | if (paths.length !== 1 || paths[0] !== path) lacks.push(`Path=${path}`); | |
| 465 | if (!/\bHttpOnly\b/.test(line)) lacks.push("HttpOnly"); | |
| 466 | if (!/\bSameSite=(?:Lax|Strict)\b/.test(line)) lacks.push("SameSite=Lax|Strict"); | |
| 467 | // Literal, conditional (`? "; Secure" : ""`), or the `${secure}` a builder took from secureFor. | |
| 468 | if (!/\bSecure\b|\$\{secure\}/.test(line)) lacks.push("Secure"); | |
| 469 | return lacks; | |
| 455 | /** The text of a call's arguments, from just after its `(` to its matching `)`. */ | |
| 456 | function argsAt(text: string, open: number): string { | |
| 457 | let depth = 1; | |
| 458 | for (let i = open; i < text.length; i++) { | |
| 459 | if (text[i] === "(") depth++; | |
| 460 | else if (text[i] === ")" && --depth === 0) return text.slice(open, i); | |
| 461 | } | |
| 462 | return text.slice(open); | |
| 470 | 463 | } |
| 471 | 464 | |
| 472 | 465 | describe("cookie flags, walked in the source", () => { |
| 473 | 466 | const files = [...new Bun.Glob("src/**/*.ts").scanSync(".")].sort().map((file) => ({ file, text: readFileSync(file, "utf8") })); |
| 474 | const spelled = files.flatMap(({ file, text }) => | |
| 475 | text.split("\n").flatMap((line, i) => { | |
| 476 | const trimmed = line.trim(); | |
| 477 | if (trimmed.startsWith("//") || trimmed.startsWith("*")) return []; | |
| 478 | const match = SPELLED.exec(line); | |
| 479 | return match ? [{ where: `${file}:${i + 1}`, name: match[1]!, line: trimmed }] : []; | |
| 480 | }), | |
| 481 | ); | |
| 467 | const CALL = /\b(?:setCookie|expireCookie)\(/g; | |
| 468 | const calls = files | |
| 469 | .filter(({ file }) => file !== HELPER) | |
| 470 | .flatMap(({ file, text }) => | |
| 471 | [...text.matchAll(CALL)].map((m) => { | |
| 472 | const args = argsAt(text, m.index + m[0].length); | |
| 473 | const first = args.slice(0, args.search(/,(?![^(]*\))/)).trim().replace(/^["'`]|["'`]$/g, ""); | |
| 474 | return { file, where: `${file}:${text.slice(0, m.index).split("\n").length}`, name: first, args }; | |
| 475 | }), | |
| 476 | ); | |
| 482 | 477 | |
| 483 | test("every cookie spelled in src/ has its entry in COOKIES, and no entry is stale", () => { | |
| 478 | test("the helper turns every flag on, Secure only when asked, and refuses a line it cannot keep whole", async () => { | |
| 479 | const { setCookie, expireCookie } = await import("../src/auth/cookies.ts"); | |
| 480 | expect(setCookie("forge_x", "v1", { maxAge: 60, secure: true })).toBe("forge_x=v1; Path=/; HttpOnly; SameSite=Lax; Max-Age=60; Secure"); | |
| 481 | expect(setCookie("forge_x", "v1", { path: "/pricing", expires: new Date(0), secure: false })).toBe( | |
| 482 | "forge_x=v1; Path=/pricing; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT", | |
| 483 | ); | |
| 484 | expect(expireCookie("forge_x", { path: "/settings/origins", secure: true })).toBe("forge_x=; Path=/settings/origins; HttpOnly; SameSite=Lax; Max-Age=0; Secure"); | |
| 485 | for (const bad of [ | |
| 486 | () => setCookie("a b", "v", { secure: false }), | |
| 487 | () => setCookie("a", "v; Domain=evil", { secure: false }), | |
| 488 | () => setCookie("a", "v\r\nX: y", { secure: false }), | |
| 489 | () => setCookie("a", "v", { path: "/x; SameSite=None", secure: false }), | |
| 490 | () => setCookie("a", "v", { path: "relative", secure: false }), | |
| 491 | () => setCookie("a", "v", { maxAge: -1, secure: false }), | |
| 492 | () => setCookie("a", "v", { maxAge: 1.5, secure: false }), | |
| 493 | ]) | |
| 494 | expect(bad).toThrow(); | |
| 495 | }); | |
| 496 | ||
| 497 | test("no line in src/ spells a cookie outside the helper", () => { | |
| 498 | const stray = files | |
| 499 | .filter(({ file, text }) => file !== HELPER && /Path|Max-Age|Expires|Domain|HttpOnly|SameSite|Secure/.test(text)) | |
| 500 | .flatMap(({ file, text }) => | |
| 501 | text.split("\n").flatMap((line, i) => { | |
| 502 | const trimmed = line.trim(); | |
| 503 | if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) return []; | |
| 504 | return SPELLED.test(line) || /;\s*(?:HttpOnly|SameSite=)/.test(line) ? [`${file}:${i + 1}: ${trimmed.slice(0, 100)}`] : []; | |
| 505 | }), | |
| 506 | ); | |
| 507 | expect(stray).toEqual([]); | |
| 508 | }); | |
| 509 | ||
| 510 | test("every call to the helper has its entry in COOKIES, and no entry is stale", () => { | |
| 484 | 511 | const counts = new Map<string, string[]>(); |
| 485 | for (const s of spelled) { | |
| 486 | const key = `${s.where.replace(/:\d+$/, "")} ${s.name}`; | |
| 487 | counts.set(key, [...(counts.get(key) ?? []), s.where]); | |
| 512 | for (const c of calls) { | |
| 513 | const key = `${c.file} ${c.name}`; | |
| 514 | counts.set(key, [...(counts.get(key) ?? []), c.where]); | |
| 488 | 515 | } |
| 489 | 516 | const wrong: string[] = []; |
| 490 | 517 | for (const [key, at] of counts) { |
| 491 | 518 | const entry = COOKIES[key]; |
| 492 | 519 | if (entry === undefined) wrong.push(`${at.join(", ")}: a cookie \`${key.split(" ")[1]}\` with no entry in COOKIES`); |
| 493 | else if (entry.lines !== at.length) wrong.push(`${at.join(", ")}: ${at.length} lines spell \`${key}\`, COOKIES says ${entry.lines}`); | |
| 520 | else if (entry.lines !== at.length) wrong.push(`${at.join(", ")}: ${at.length} calls set \`${key}\`, COOKIES says ${entry.lines}`); | |
| 494 | 521 | } |
| 495 | for (const key of Object.keys(COOKIES)) if (!counts.has(key)) wrong.push(`${key}: in COOKIES but spelled nowhere in src/`); | |
| 522 | for (const key of Object.keys(COOKIES)) if (!counts.has(key)) wrong.push(`${key}: in COOKIES but set nowhere in src/`); | |
| 496 | 523 | expect(wrong).toEqual([]); |
| 497 | 524 | }); |
| 498 | 525 | |
| 499 | 526 | test("the walk finds the forge's cookies, including those no door above reaches", () => { |
| 500 | const where = new Set(spelled.map((s) => s.where.replace(/:\d+$/, ""))); | |
| 527 | const where = new Set(calls.map((c) => c.file)); | |
| 501 | 528 | 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"]) |
| 502 | 529 | expect([file, where.has(file)]).toEqual([file, true]); |
| 503 | 530 | }); |
| 504 | 531 | |
| 505 | test("every cookie spelled in src/ is HttpOnly, SameSite and Secure, or exempt with a reason", () => { | |
| 506 | const short = spelled | |
| 507 | .filter((s) => EXEMPT[s.name] === undefined) | |
| 508 | .map((s) => ({ ...s, lacks: lacking(s.line, COOKIES[`${s.where.replace(/:\d+$/, "")} ${s.name}`]?.path?.path ?? "/") })) | |
| 509 | .filter((s) => s.lacks.length > 0) | |
| 510 | .map((s) => `${s.where} ${s.name} lacks ${s.lacks.join(", ")}`); | |
| 511 | expect(short).toEqual([]); | |
| 532 | test("every call scopes the path COOKIES names and takes Secure from the https rule, not a literal", () => { | |
| 533 | const wrong: string[] = []; | |
| 534 | for (const c of calls) { | |
| 535 | const want = COOKIES[`${c.file} ${c.name}`]?.path?.path ?? "/"; | |
| 536 | const path = /\bpath:\s*["'`]([^"'`]*)["'`]/.exec(c.args)?.[1] ?? "/"; | |
| 537 | if (path !== want) wrong.push(`${c.where} ${c.name}: Path=${path}, COOKIES says ${want}`); | |
| 538 | if (/\bsecure:\s*(?:true|false)\b/.test(c.args)) wrong.push(`${c.where} ${c.name}: a literal secure; take it from secureFor`); | |
| 539 | } | |
| 540 | expect(wrong).toEqual([]); | |
| 512 | 541 | }); |
| 513 | 542 | |
| 514 | test("every set-cookie writer passes a checked literal or a known builder", () => { | |
| 543 | test("every set-cookie writer passes the helper or a builder that calls it", () => { | |
| 515 | 544 | const writer = /(?:\.(?:append|set)\(\s*["']set-cookie["']\s*,|["']set-cookie["']\s*:)\s*([^\n]+)/gi; |
| 516 | 545 | const unknown: string[] = []; |
| 517 | 546 | let writers = 0; |
| @@ -520,27 +549,23 @@ describe("cookie flags, walked in the source", () => { | ||
| 520 | 549 | writers++; |
| 521 | 550 | const value = match[1]!.trim(); |
| 522 | 551 | const at = `${file}:${text.slice(0, match.index).split("\n").length}`; |
| 523 | if (value.startsWith("`")) { | |
| 524 | const spelledName = SPELLED.exec(value)?.[1]; | |
| 525 | const lacks = spelledName !== undefined ? lacking(value, COOKIES[`${file} ${spelledName}`]?.path?.path ?? "/") : ["every flag"]; | |
| 526 | if (lacks.length > 0) unknown.push(`${at} inline cookie lacks ${lacks.join(", ")}`); | |
| 527 | continue; | |
| 528 | } | |
| 529 | 552 | const known = Object.keys(BUILDERS).some((b) => |
| 530 | 553 | b.endsWith("(") ? value.startsWith(b) : new RegExp(`^${b.replace(".", "\\.")}\\b`).test(value), |
| 531 | 554 | ); |
| 532 | if (!known) unknown.push(`${at} passes \`${value}\`: build it with a checked builder or spell it inline`); | |
| 555 | if (!known) unknown.push(`${at} passes \`${value}\`: build it with setCookie from ${HELPER}`); | |
| 533 | 556 | } |
| 534 | 557 | } |
| 535 | 558 | expect(writers).toBeGreaterThan(10); |
| 536 | 559 | expect(unknown).toEqual([]); |
| 537 | 560 | }); |
| 538 | 561 | |
| 539 | test("the exempt list holds no credential cookie and nothing stale", () => { | |
| 540 | for (const [name, reason] of Object.entries(EXEMPT)) { | |
| 541 | expect([name, NEVER_EXEMPT.test(name)]).toEqual([name, false]); | |
| 542 | expect(reason.trim().length).toBeGreaterThan(10); | |
| 543 | expect([name, spelled.some((s) => s.name === name)]).toEqual([name, true]); | |
| 562 | test("each builder BUILDERS names calls the helper", () => { | |
| 563 | const text = new Map(files.map((f) => [f.file, f.text])); | |
| 564 | for (const [b, from] of Object.entries(BUILDERS)) { | |
| 565 | if (!b.endsWith("(") || from === HELPER) continue; | |
| 566 | const body = text.get(from.split(",")[0]!)!; | |
| 567 | const at = body.search(new RegExp(`(?:function |const )${b.slice(0, -1)}\\b`)); | |
| 568 | expect([b, at >= 0 && /\b(?:setCookie|expireCookie)\(/.test(body.slice(at, at + 600))]).toEqual([b, true]); | |
| 544 | 569 | } |
| 545 | 570 | }); |
| 546 | 571 | }); |
test/studio.test.ts +1 −1
| @@ -147,7 +147,7 @@ describe("the studio", () => { | ||
| 147 | 147 | expect(res.headers.get("location")).toBe( |
| 148 | 148 | path === "/?mode=dev" ? "/" : "/alice/notes/issues?word=told", |
| 149 | 149 | ); |
| 150 | expect(res.headers.get("set-cookie")).toContain("forge_mode=; Max-Age=0"); | |
| 150 | expect(res.headers.get("set-cookie")).toMatch(/^forge_mode=;.*\bMax-Age=0\b/); | |
| 151 | 151 | } |
| 152 | 152 | // An old dev cookie changes nothing. |
| 153 | 153 | const withDev = await ( |
new above
offline