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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/12314-every-cookie-the-forge-sets-is-httponly → main 7 commits fork

  • Merged. scout/12314-every-cookie-the-forge-sets-is-httponly was merged into main as 84c60a0.

Review

Sign in to review this change.
15 files changed +200 −115
src/apps/feedback.ts +2 −1
@@ -15,6 +15,7 @@
1515 * keyed on the socket's client (`http/peer.ts`), never on a header the
1616 * client wrote.
1717 */
18 import { setCookie } from "../auth/cookies.ts";
1819 import { randomBytes } from "node:crypto";
1920 import type { App } from "../app.ts";
2021 import { notesFor, sendNote, type Sender } from "../actions/app_notes.ts";
@@ -142,7 +143,7 @@ function senderOf(ask: FeedbackAsk, door: Door): { sender: Sender; fresh?: strin
142143 }
143144
144145 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 });
146147
147148 /** The body as JSON, read no further than `REQUEST_MAX` bytes whatever it declares. */
148149 async function readJson(request: Request): Promise<Record<string, unknown> | undefined> {
src/apps/proxy.ts +3 −2
@@ -15,6 +15,7 @@
1515 * directory — so guessing a hostname yields the forge's own "not found".
1616 */
1717
18 import { expireCookie, setCookie } from "../auth/cookies.ts";
1819 import { compressBytes, MIN_BYTES, negotiate } from "../http/compress.ts";
1920 import type { Database } from "bun:sqlite";
2021 import { getIssue, type Issue } from "../core/issues.ts";
@@ -1062,7 +1063,7 @@ export async function proxyApp(
10621063 status: 303,
10631064 headers: {
10641065 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 }),
10661067 "cache-control": "no-store",
10671068 },
10681069 }),
@@ -1814,7 +1815,7 @@ function grant(
18141815 status: 303,
18151816 headers: {
18161817 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 }),
18181819 "cache-control": "no-store",
18191820 },
18201821 });
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 @@
1111 * Both are GETs that change nothing a stranger could use: a callback with
1212 * no matching cookie and state is refused before anything is fetched.
1313 */
14 import { setCookie } from "../auth/cookies.ts";
1415 import { secureFor } from "./cookie_secure.ts";
1516 import { createHash, randomBytes } from "node:crypto";
1617 import type { App } from "../app.ts";
@@ -122,10 +123,8 @@ function failure(ctx: Ctx, message: string, status: number): Response {
122123 return response;
123124 }
124125
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) });
129128
130129 export const parentStart = (ctx: Ctx): Promise<Response> => begin(ctx);
131130
src/http/server.ts +2 −2
@@ -5,6 +5,7 @@
55 * verdict. Auth is resolved once per request and handed to handlers as data.
66 */
77
8 import { expireCookie } from "../auth/cookies.ts";
89 import { checkText } from "./check_text.ts";
910 import { appSecretGrant, appSecretRevoke } from "../web/pages/app_secrets.ts";
1011 import { apiRoot } from "./api_root.ts";
@@ -2004,12 +2005,11 @@ function withoutMode(url: URL, request: Request, forgeUrl?: string): Response |
20042005 if (request.method !== "GET" || !url.searchParams.has("mode")) return undefined;
20052006 const clean = new URL(url);
20062007 clean.searchParams.delete("mode");
2007 const secure = secureFor(url, forgeUrl) ? "; Secure" : "";
20082008 return new Response(null, {
20092009 status: 303,
20102010 headers: {
20112011 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) }),
20132013 "cache-control": "no-store",
20142014 },
20152015 });
src/perf/visitor_id.ts +2 −1
@@ -11,6 +11,7 @@
1111 * client: no canvas, fonts, audio or WebGL.
1212 */
1313
14 import { setCookie } from "../auth/cookies.ts";
1415 import { createHmac, timingSafeEqual } from "node:crypto";
1516 import { sha256 } from "../kernel/hash.ts";
1617 import { addressBytes } from "../kernel/cidr.ts";
@@ -102,7 +103,7 @@ export function probableId(salt: string, headers: Headers, address: string): str
102103
103104 /** The Set-Cookie line for a new id: HttpOnly, Secure when the forge is https, SameSite=Lax, 13 months, this host only (no Domain). */
104105 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 });
106107
107108 /** How far a visitor got; the highest step seen is kept. */
108109 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 @@
3737 * provider. All a child could do with the call is refresh what it already holds.
3838 */
3939
40 import { expireCookie, setCookie } from "../auth/cookies.ts";
4041 import { timingSafeEqual } from "node:crypto";
4142 import type { App } from "../app.ts";
4243 import { childNamed, beatKeyOf, liveChildUrl } from "../actions/spawn.ts";
@@ -336,14 +337,13 @@ export async function brokerStart(
336337 redirectUri,
337338 scope: scopesFor(found.value.kind, "oauth", software),
338339 });
339 const secure = secureFor(ctx.url, app.config.url) ? "; Secure" : "";
340340 const response = brokerAskPage(ctx, {
341341 child: asking.name,
342342 childUrl: liveChildUrl(app, asking.name),
343343 host: offer.host,
344344 go: url,
345345 });
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) }));
347347 return response;
348348 }
349349
@@ -505,7 +505,7 @@ export async function brokerCallback(
505505 }
506506
507507 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) });
509509
510510 // ---- the child's signed calls ----
511511
src/web/flash.ts +3 −4
@@ -1,3 +1,4 @@
1 import { expireCookie, setCookie } from "../auth/cookies.ts";
12 import { secureFor } from "../http/cookie_secure.ts";
23
34 /**
@@ -31,14 +32,12 @@ const MAX_FLASH = 2000;
3132 /** The `Set-Cookie` value that carries a flash to the next request. */
3233 export function flashCookie(flash: Flash, url?: URL, forgeUrl?: string): string {
3334 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) });
3636 }
3737
3838 /** The `Set-Cookie` value that clears it, sent with the page that showed it. */
3939 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) });
4241 }
4342
4443 /**
src/web/pages/home_flow.ts +2 −2
@@ -15,6 +15,7 @@
1515 * version without being drawn. The page is a static picture with scripts off.
1616 */
1717
18 import { setCookie } from "../../auth/cookies.ts";
1819 import type { App } from "../../app.ts";
1920 import { listChildren } from "../../actions/spawn.ts";
2021 import { lastHeard, MAX_CHILDREN, QUIET_MS } from "../../spawn/heartbeat.ts";
@@ -545,8 +546,7 @@ export async function homeFlowPage(ctx: Ctx, known?: { readonly spawned: Awaited
545546 const res = page({ ctx, title: "Home", body, wide: true, scriptJs: `${liveRegionsJs}\n${homeFlowJs}` });
546547 // The view asked for in the address is remembered for the next visit.
547548 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) }));
550550 }
551551 notePhase(ctx.request, "render", performance.now() - rendering);
552552 return res;
src/web/pages/map.ts +2 −1
@@ -19,6 +19,7 @@
1919 * can see, one lane each.
2020 */
2121
22 import { setCookie } from "../../auth/cookies.ts";
2223 import { mayDirectCrew } from "../../crew/direct.ts";
2324 import { faceAt } from "../pixel.ts";
2425 import type { MapHold } from "../../crew/held.ts";
@@ -1466,7 +1467,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
14661467 if (seen.cookie !== undefined)
14671468 response.headers.append(
14681469 "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) }),
14701471 );
14711472 return response;
14721473 }
src/web/pages/pricing.ts +2 −1
@@ -6,6 +6,7 @@
66 * say "ask the operator". The rules are in `actions/order.ts`.
77 */
88
9 import { setCookie } from "../../auth/cookies.ts";
910 import { deliverLink, mayView, NO_PROVIDER, placeOrder, reconcileOrder, resendLink, type Refusal } from "../../actions/order.ts";
1011 import { checkoutOf } from "../../billing/checkout.ts";
1112 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
@@ -103,7 +104,7 @@ export function pricingPage(ctx: Ctx): Response {
103104 }
104105
105106 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) });
107108
108109 // POST /pricing/order — free goes on to the free path; a paid plan holds the name and goes to checkout.
109110 export async function pricingOrder(ctx: Ctx): Promise<Response> {
src/web/pages/site.ts +5 −5
@@ -8,6 +8,7 @@
88 * typed still in it. A dead end that loses input is a bug, not a message.
99 */
1010
11 import { expireCookie, setCookie } from "../../auth/cookies.ts";
1112 import { safeNext, withReturn } from "../return_to.ts";
1213 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1314 import { keepGift } from "../gift_cookie.ts";
@@ -79,8 +80,7 @@ export function sessionCookie(
7980 ): string {
8081 // Secure when the request or the forge's own address is https: behind a
8182 // 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) });
8484 }
8585
8686 export { safeNext };
@@ -784,10 +784,10 @@ export async function themeSubmit(ctx: Ctx): Promise<Response> {
784784 const form = await readForm(ctx, undefined, STRANGER_MAX);
785785 if (form instanceof Response) return form;
786786 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);
788788 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 });
791791
792792 // Return to the page the form was submitted from — same-origin paths only,
793793 // so the redirect can never be steered off this forge.
test/studio.test.ts +1 −1
@@ -147,7 +147,7 @@ describe("the studio", () => {
147147 expect(res.headers.get("location")).toBe(
148148 path === "/?mode=dev" ? "/" : "/alice/notes/issues?word=told",
149149 );
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/);
151151 }
152152 // An old dev cookie changes nothing.
153153 const withDev = await (

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs