Public: anyone can read this; the crew can change it.

One rule decides `Secure` on every cookie the forge sets: the visitor cookie on an app host is Secure over https #6117

Merged opened this

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

Merged

scout/forge:scout/6095-one-rule-decides-secure-on-every-cookie → main 4 commits fork 5 threads · 5 unresolved

  • Merged. scout/6095-one-rule-decides-secure-on-every-cookie was merged into main as 017fae5.

Review

Sign in to review this change.
14 files changed +164 −27
src/apps/proxy.ts +8 −9
@@ -45,8 +45,13 @@ import { readServing } from "./serving.ts";
4545 import { notFound, securityFloor } from "../http/respond.ts";
4646 import { appMeasure } from "./measure.ts";
4747 import { holdPortRequest, holdPortSocket } from "./handoff.ts";
48 import { secureFor } from "../http/cookie_secure.ts";
4849 import { htmlAnswer, readPage, rewritable, withToolbar, type PillWhere } from "./toolbar.ts";
4950
51 const proxySecure = (request: Request, url: URL, forgeUrl: string): boolean =>
52 secureFor(url, forgeUrl) ||
53 request.headers.get("x-forwarded-proto") === "https";
54
5055 export interface AppTarget {
5156 readonly app: AppRecord;
5257 readonly env: EnvName;
@@ -599,9 +604,7 @@ export async function proxyApp(
599604 );
600605 }
601606 if (url.pathname === "/__forge/signout" && !target.nested) {
602 const secure =
603 url.protocol === "https:" ||
604 request.headers.get("x-forwarded-proto") === "https";
607 const secure = proxySecure(request, url, runtime.options.forgeUrl);
605608 return securityFloor(
606609 new Response(null, {
607610 status: 303,
@@ -752,9 +755,7 @@ export async function proxyApp(
752755 sealed,
753756 viewerId,
754757 grantCookie: cookie,
755 secure:
756 url.protocol === "https:" ||
757 request.headers.get("x-forwarded-proto") === "https",
758 secure: proxySecure(request, url, runtime.options.forgeUrl),
758759 source: clientBucket(request),
759760 });
760761 if (answer !== undefined) return securityFloor(answer);
@@ -1252,9 +1253,7 @@ function grant(
12521253 vouched.who,
12531254 );
12541255 const path = safePath(url.searchParams.get("to"));
1255 const secure =
1256 url.protocol === "https:" ||
1257 request.headers.get("x-forwarded-proto") === "https";
1256 const secure = proxySecure(request, url, runtime.options.forgeUrl);
12581257 return new Response(null, {
12591258 status: 303,
12601259 headers: {
src/http/rp.ts +3 −2
@@ -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 { secureFor } from "./cookie_secure.ts";
1415 import { createHash, randomBytes } from "node:crypto";
1516 import type { App } from "../app.ts";
1617 import {
@@ -117,8 +118,8 @@ function failure(ctx: Ctx, message: string, status: number): Response {
117118 return response;
118119 }
119120
120 const cookieHeader = (value: string, maxAge: number, url: URL, forgeUrl?: string): string => {
121 const secure = url.protocol === "https:" || forgeUrl?.startsWith("https://") ? "; Secure" : "";
121 export const cookieHeader = (value: string, maxAge: number, url: URL, forgeUrl?: string): string => {
122 const secure = secureFor(url, forgeUrl) ? "; Secure" : "";
122123 return `${STATE_COOKIE}=${value}; Path=/signin/parent; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`;
123124 };
124125
src/http/server.ts +4 −3
@@ -6,6 +6,7 @@
66 */
77
88 import { followUpstreamCheck, followUpstreamUpdate } from "../web/pages/upstream-follow.ts";
9 import { secureFor } from "./cookie_secure.ts";
910 import { refusedByDatabase, shownRefusal } from "./refusal.ts";
1011 import { NOT_A_MEMBER } from "../core/roles.ts";
1112 import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts";
@@ -1678,11 +1679,11 @@ function withRateHeaders(
16781679 * a redirect to its own clean address, and the cookie that used to hold the
16791680 * choice is let go on the way.
16801681 */
1681 function withoutMode(url: URL, request: Request): Response | undefined {
1682 function withoutMode(url: URL, request: Request, forgeUrl?: string): Response | undefined {
16821683 if (request.method !== "GET" || !url.searchParams.has("mode")) return undefined;
16831684 const clean = new URL(url);
16841685 clean.searchParams.delete("mode");
1685 const secure = url.protocol === "https:" ? "; Secure" : "";
1686 const secure = secureFor(url, forgeUrl) ? "; Secure" : "";
16861687 return new Response(null, {
16871688 status: 303,
16881689 headers: {
@@ -2578,7 +2579,7 @@ async function answerGet(
25782579
25792580 const themeCookie = cookieValue(request, "forge_theme") ?? "";
25802581 if (!isApiPath) {
2581 const clean = withoutMode(url, request);
2582 const clean = withoutMode(url, request, app.config.url);
25822583 if (clean !== undefined) return clean;
25832584 }
25842585 const invoke = (into: Request): Response | Promise<Response> =>
src/web/flash.ts +6 −4
@@ -1,3 +1,5 @@
1 import { secureFor } from "../http/cookie_secure.ts";
2
13 /**
24 * Flash messages: the missing half of post-redirect-get. A mutation sets a
35 * short-lived cookie, the next page renders it exactly once as a `.toast` and
@@ -27,15 +29,15 @@ export interface Flash {
2729 const MAX_FLASH = 2000;
2830
2931 /** The `Set-Cookie` value that carries a flash to the next request. */
30 export function flashCookie(flash: Flash, url?: URL): string {
32 export function flashCookie(flash: Flash, url?: URL, forgeUrl?: string): string {
3133 const encoded = encodeURIComponent(JSON.stringify(flash)).slice(0, MAX_FLASH);
32 const secure = url?.protocol === "https:" ? "; Secure" : "";
34 const secure = secureFor(url, forgeUrl) ? "; Secure" : "";
3335 return `${FLASH_COOKIE}=${encoded}; Path=/; Max-Age=60; SameSite=Lax; HttpOnly${secure}`;
3436 }
3537
3638 /** The `Set-Cookie` value that clears it, sent with the page that showed it. */
37 export function clearFlashCookie(url?: URL): string {
38 const secure = url?.protocol === "https:" ? "; Secure" : "";
39 export function clearFlashCookie(url?: URL, forgeUrl?: string): string {
40 const secure = secureFor(url, forgeUrl) ? "; Secure" : "";
3941 return `${FLASH_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly${secure}`;
4042 }
4143
src/web/layout.ts +5 −3
@@ -678,7 +678,7 @@ export function page(options: PageOptions, status = 200): Response {
678678 link: linkHeader(links),
679679 });
680680 // A flash is shown exactly once: rendering it is also what clears it.
681 if (cookieFlash) headers.append("set-cookie", clearFlashCookie(ctx.url));
681 if (cookieFlash) headers.append("set-cookie", clearFlashCookie(ctx.url, ctx.app.config.url));
682682 if (tagged) {
683683 const etag = weakEtag(text);
684684 headers.set("etag", etag);
@@ -697,6 +697,8 @@ export interface RedirectOptions {
697697 flash?: Flash;
698698 /** Needed only so the flash cookie learns whether to be Secure. */
699699 url?: URL;
700 /** The forge's own address: https there makes the cookie Secure behind a TLS-ending proxy. */
701 forgeUrl?: string | undefined;

Low: this is optional, so the one rule depends on each caller remembering to pass it, and every flash redirect in this diff had to be edited by hand to add it. A later redirect(to, { flash, url: ctx.url }) compiles and silently loses Secure behind the TLS-ending proxy. Fix: derive the flag from Ctx (e.g. redirect(ctx, to, …), or have flash redirects go only through flashTo), so leaving it out is impossible.

Every flash redirect now has to remember both url and forgeUrl. A caller that forgets forgeUrl silently falls back to not Secure, and nothing catches it. Optional, but sturdier: have redirect take secure?: boolean computed once by the caller with secureFor, or route these call sites through flashTo(ctx, …), which already has ctx.

Optional forgeUrl means a future redirect(..., { flash, url }) that forgets it silently falls back to the request scheme, which is the bug this pull fixes. flashTo(ctx, …) already covers most callers. Later, consider routing the remaining direct redirect callers through a ctx-taking helper so the rule can't be skipped.

700702 }
701703
702704 export function redirect(
@@ -708,7 +710,7 @@ export function redirect(
708710 const headers = new Headers({ location: to });
709711 if (o.cookie !== undefined) headers.append("set-cookie", o.cookie);
710712 if (o.flash !== undefined)
711 headers.append("set-cookie", flashCookie(o.flash, o.url));
713 headers.append("set-cookie", flashCookie(o.flash, o.url, o.forgeUrl));
712714 return new Response(null, { status: 303, headers });
713715 }
714716
@@ -768,7 +770,7 @@ export function missingFragment(request: Request): Response {
768770
769771 /** Redirect back to a page carrying a message — the shape most handlers want. */
770772 export function flashTo(ctx: Ctx, to: string, flash: Flash): Response {
771 return redirect(to, { flash, url: ctx.url });
773 return redirect(to, { flash, url: ctx.url, forgeUrl: ctx.app.config.url });
772774 }
773775
774776 /**
src/web/pages/apps.ts +1
@@ -794,6 +794,7 @@ export async function appRedeploy(ctx: Ctx): Promise<Response> {
794794 // there (`envBack`); the run itself is one link away on that card.
795795 return redirect(envBack(ctx, repo), {
796796 url: ctx.url,
797 forgeUrl: ctx.app.config.url,
797798 flash: {
798799 message:
799800 run === undefined
src/web/pages/everyday.ts +2
@@ -218,6 +218,7 @@ export async function everydayCreate(ctx: Ctx): Promise<Response> {
218218 return redirect("/", {
219219 cookie: sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url),
220220 url: ctx.url,
221 forgeUrl: ctx.app.config.url,
221222 flash: {
222223 message: `You are signed in as ${made.value.username}, your everyday account. Sign in as ${admin.username} when you mean to change the forge.`,
223224 },
@@ -251,6 +252,7 @@ export async function everydaySwitch(ctx: Ctx): Promise<Response> {
251252 return redirect("/signin", {
252253 cookie: sessionCookie("", new Date(0), ctx.url, ctx.app.config.url),
253254 url: ctx.url,
255 forgeUrl: ctx.app.config.url,
254256 flash: { message: `Sign in as ${daily.username}, your everyday account.` },
255257 });
256258 }
src/web/pages/site.ts +3 −5
@@ -52,6 +52,7 @@ import {
5252 } from "../ui.ts";
5353 import { existsSync } from "node:fs";
5454 import { join } from "node:path";
55 import { secureFor } from "../../http/cookie_secure.ts";
5556 import { hasAdmin } from "../../spawn/claim.ts";
5657 import { getSignup, type SignupMode } from "../../settings/signup.ts";
5758 import { EMAIL_TAKEN_MESSAGE, NAME_RESERVED, USERNAME_TAKEN } from "../../actions/signup_words.ts";
@@ -64,10 +65,7 @@ export function sessionCookie(
6465 ): string {
6566 // Secure when the request or the forge's own address is https: behind a
6667 // proxy that ends TLS the request itself arrives as http.
67 const secure =
68 url.protocol === "https:" || forgeUrl?.startsWith("https://")
69 ? "; Secure"
70 : "";
68 const secure = secureFor(url, forgeUrl) ? "; Secure" : "";
7169 return `forge_session=${value}; Path=/; HttpOnly; SameSite=Lax; Expires=${expires.toUTCString()}${secure}`;
7270 }
7371
@@ -696,7 +694,7 @@ export async function themeSubmit(ctx: Ctx): Promise<Response> {
696694 const form = await readForm(ctx, undefined, STRANGER_MAX);
697695 if (form instanceof Response) return form;
698696 const choice = field(form, "theme");
699 const secure = ctx.url.protocol === "https:" ? "; Secure" : "";
697 const secure = secureFor(ctx.url, ctx.app.config.url) ? "; Secure" : "";
700698 const cookie = isThemeName(choice)
701699 ? `forge_theme=${choice}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly${secure}`
702700 : `forge_theme=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly${secure}`;
src/web/studio/home.ts +1
@@ -629,6 +629,7 @@ export async function frontUpdate(ctx: Ctx): Promise<Response> {
629629 const set = setFront(ctx.app, ctx.auth.user, next);
630630 return redirect("/front", {
631631 url: ctx.url,
632 forgeUrl: ctx.app.config.url,
632633 flash: set.ok
633634 ? { message: "The front page is updated." }
634635 : {
src/web/studio/home_archive.ts +3
@@ -34,6 +34,7 @@ export async function homeArchive(ctx: Ctx): Promise<Response> {
3434 if (ids.length === 0 || ids.length > MAX_ROWS)
3535 return redirect("/", {
3636 url: ctx.url,
37 forgeUrl: ctx.app.config.url,
3738 flash: { tone: "danger", message: "Tick the apps to archive first." },
3839 });
3940 const op = await applyFleetIds(
@@ -47,6 +48,7 @@ export async function homeArchive(ctx: Ctx): Promise<Response> {
4748 if (!op.ok)
4849 return redirect("/", {
4950 url: ctx.url,
51 forgeUrl: ctx.app.config.url,
5052 flash: { tone: "danger", message: op.error.message },
5153 });
5254 return redirect(`/home/archived/${op.value.id}`);
@@ -110,6 +112,7 @@ export async function homeArchiveUndo(ctx: Ctx): Promise<Response> {
110112 if (!undone.ok)
111113 return redirect("/", {
112114 url: ctx.url,
115 forgeUrl: ctx.app.config.url,
113116 flash: { tone: "danger", message: undone.error.message },
114117 });
115118 return redirect(`/home/archived/${undone.value.id}`);
src/web/studio/make_it_yours.ts +1
@@ -165,6 +165,7 @@ export async function makeItYoursSubmit(ctx: Ctx): Promise<Response> {
165165 }
166166 return redirect(repoBase(made.value.made.repo), {
167167 url: ctx.url,
168 forgeUrl: ctx.app.config.url,
168169 flash: {
169170 message: made.value.existing
170171 ? `This is your ${made.value.repo}.`
src/web/studio/review_act.ts +1 −1
@@ -143,5 +143,5 @@ export async function reviewAct(ctx: Ctx): Promise<Response> {
143143 const after = orderedAsks(ctx.app.db, user).filter((a) => !acted(a));
144144 const later = new Set(before.slice(at + 1).map((a) => a.id));
145145 const next = at < 0 ? after[0] : (after.find((a) => later.has(a.id)) ?? after[0]);
146 return redirect(back(next), { flash: { message: done }, url: ctx.url });
146 return redirect(back(next), { flash: { message: done }, url: ctx.url, forgeUrl: ctx.app.config.url });
147147 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs