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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6095-one-rule-decides-secure-on-every-cookie → main 4 commits fork 5 threads · 5 unresolved
src/apps/proxy.ts +8 −9
| @@ -45,8 +45,13 @@ import { readServing } from "./serving.ts"; | ||
| 45 | 45 | import { notFound, securityFloor } from "../http/respond.ts"; |
| 46 | 46 | import { appMeasure } from "./measure.ts"; |
| 47 | 47 | import { holdPortRequest, holdPortSocket } from "./handoff.ts"; |
| 48 | import { secureFor } from "../http/cookie_secure.ts"; | |
| 48 | 49 | import { htmlAnswer, readPage, rewritable, withToolbar, type PillWhere } from "./toolbar.ts"; |
| 49 | 50 | |
| 51 | const proxySecure = (request: Request, url: URL, forgeUrl: string): boolean => | |
| 52 | secureFor(url, forgeUrl) || | |
| 53 | request.headers.get("x-forwarded-proto") === "https"; | |
| 54 | ||
| 50 | 55 | export interface AppTarget { |
| 51 | 56 | readonly app: AppRecord; |
| 52 | 57 | readonly env: EnvName; |
| @@ -599,9 +604,7 @@ export async function proxyApp( | ||
| 599 | 604 | ); |
| 600 | 605 | } |
| 601 | 606 | 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); | |
| 605 | 608 | return securityFloor( |
| 606 | 609 | new Response(null, { |
| 607 | 610 | status: 303, |
| @@ -752,9 +755,7 @@ export async function proxyApp( | ||
| 752 | 755 | sealed, |
| 753 | 756 | viewerId, |
| 754 | 757 | grantCookie: cookie, |
| 755 | secure: | |
| 756 | url.protocol === "https:" || | |
| 757 | request.headers.get("x-forwarded-proto") === "https", | |
| 758 | secure: proxySecure(request, url, runtime.options.forgeUrl), | |
| 758 | 759 | source: clientBucket(request), |
| 759 | 760 | }); |
| 760 | 761 | if (answer !== undefined) return securityFloor(answer); |
| @@ -1252,9 +1253,7 @@ function grant( | ||
| 1252 | 1253 | vouched.who, |
| 1253 | 1254 | ); |
| 1254 | 1255 | 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); | |
| 1258 | 1257 | return new Response(null, { |
| 1259 | 1258 | status: 303, |
| 1260 | 1259 | headers: { |
src/http/cookie_secure.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | /** | |
| 2 | * The one rule for `Secure` on every cookie the forge sets: the request is | |
| 3 | * https, or the forge's own address is https. Behind a proxy that ends TLS | |
| 4 | * the request itself arrives as http, so the configured address is the fact | |
| 5 | * and the request is only a hint. | |
| 6 | */ | |
| 7 | export function secureFor(url: URL | undefined, forgeUrl?: string): boolean { | |
| 8 | return url?.protocol === "https:" || forgeUrl?.startsWith("https://") === true; | |
| 9 | } |
src/http/rp.ts +3 −2
| @@ -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 { secureFor } from "./cookie_secure.ts"; | |
| 14 | 15 | import { createHash, randomBytes } from "node:crypto"; |
| 15 | 16 | import type { App } from "../app.ts"; |
| 16 | 17 | import { |
| @@ -117,8 +118,8 @@ function failure(ctx: Ctx, message: string, status: number): Response { | ||
| 117 | 118 | return response; |
| 118 | 119 | } |
| 119 | 120 | |
| 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" : ""; | |
| 122 | 123 | return `${STATE_COOKIE}=${value}; Path=/signin/parent; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`; |
| 123 | 124 | }; |
| 124 | 125 |
src/http/server.ts +4 −3
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { followUpstreamCheck, followUpstreamUpdate } from "../web/pages/upstream-follow.ts"; |
| 9 | import { secureFor } from "./cookie_secure.ts"; | |
| 9 | 10 | import { refusedByDatabase, shownRefusal } from "./refusal.ts"; |
| 10 | 11 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| 11 | 12 | import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts"; |
| @@ -1678,11 +1679,11 @@ function withRateHeaders( | ||
| 1678 | 1679 | * a redirect to its own clean address, and the cookie that used to hold the |
| 1679 | 1680 | * choice is let go on the way. |
| 1680 | 1681 | */ |
| 1681 | function withoutMode(url: URL, request: Request): Response | undefined { | |
| 1682 | function withoutMode(url: URL, request: Request, forgeUrl?: string): Response | undefined { | |
| 1682 | 1683 | if (request.method !== "GET" || !url.searchParams.has("mode")) return undefined; |
| 1683 | 1684 | const clean = new URL(url); |
| 1684 | 1685 | clean.searchParams.delete("mode"); |
| 1685 | const secure = url.protocol === "https:" ? "; Secure" : ""; | |
| 1686 | const secure = secureFor(url, forgeUrl) ? "; Secure" : ""; | |
| 1686 | 1687 | return new Response(null, { |
| 1687 | 1688 | status: 303, |
| 1688 | 1689 | headers: { |
| @@ -2578,7 +2579,7 @@ async function answerGet( | ||
| 2578 | 2579 | |
| 2579 | 2580 | const themeCookie = cookieValue(request, "forge_theme") ?? ""; |
| 2580 | 2581 | if (!isApiPath) { |
| 2581 | const clean = withoutMode(url, request); | |
| 2582 | const clean = withoutMode(url, request, app.config.url); | |
| 2582 | 2583 | if (clean !== undefined) return clean; |
| 2583 | 2584 | } |
| 2584 | 2585 | 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 | ||
| 1 | 3 | /** |
| 2 | 4 | * Flash messages: the missing half of post-redirect-get. A mutation sets a |
| 3 | 5 | * short-lived cookie, the next page renders it exactly once as a `.toast` and |
| @@ -27,15 +29,15 @@ export interface Flash { | ||
| 27 | 29 | const MAX_FLASH = 2000; |
| 28 | 30 | |
| 29 | 31 | /** 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 { | |
| 31 | 33 | const encoded = encodeURIComponent(JSON.stringify(flash)).slice(0, MAX_FLASH); |
| 32 | const secure = url?.protocol === "https:" ? "; Secure" : ""; | |
| 34 | const secure = secureFor(url, forgeUrl) ? "; Secure" : ""; | |
| 33 | 35 | return `${FLASH_COOKIE}=${encoded}; Path=/; Max-Age=60; SameSite=Lax; HttpOnly${secure}`; |
| 34 | 36 | } |
| 35 | 37 | |
| 36 | 38 | /** 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" : ""; | |
| 39 | 41 | return `${FLASH_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly${secure}`; |
| 40 | 42 | } |
| 41 | 43 |
src/web/layout.ts +5 −3
| @@ -678,7 +678,7 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 678 | 678 | link: linkHeader(links), |
| 679 | 679 | }); |
| 680 | 680 | // 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)); | |
| 682 | 682 | if (tagged) { |
| 683 | 683 | const etag = weakEtag(text); |
| 684 | 684 | headers.set("etag", etag); |
| @@ -697,6 +697,8 @@ export interface RedirectOptions { | ||
| 697 | 697 | flash?: Flash; |
| 698 | 698 | /** Needed only so the flash cookie learns whether to be Secure. */ |
| 699 | 699 | 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 Every flash redirect now has to remember both Optional |
||
| 700 | 702 | } |
| 701 | 703 | |
| 702 | 704 | export function redirect( |
| @@ -708,7 +710,7 @@ export function redirect( | ||
| 708 | 710 | const headers = new Headers({ location: to }); |
| 709 | 711 | if (o.cookie !== undefined) headers.append("set-cookie", o.cookie); |
| 710 | 712 | 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)); | |
| 712 | 714 | return new Response(null, { status: 303, headers }); |
| 713 | 715 | } |
| 714 | 716 | |
| @@ -768,7 +770,7 @@ export function missingFragment(request: Request): Response { | ||
| 768 | 770 | |
| 769 | 771 | /** Redirect back to a page carrying a message — the shape most handlers want. */ |
| 770 | 772 | 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 }); | |
| 772 | 774 | } |
| 773 | 775 | |
| 774 | 776 | /** |
src/web/pages/apps.ts +1
| @@ -794,6 +794,7 @@ export async function appRedeploy(ctx: Ctx): Promise<Response> { | ||
| 794 | 794 | // there (`envBack`); the run itself is one link away on that card. |
| 795 | 795 | return redirect(envBack(ctx, repo), { |
| 796 | 796 | url: ctx.url, |
| 797 | forgeUrl: ctx.app.config.url, | |
| 797 | 798 | flash: { |
| 798 | 799 | message: |
| 799 | 800 | run === undefined |
src/web/pages/everyday.ts +2
| @@ -218,6 +218,7 @@ export async function everydayCreate(ctx: Ctx): Promise<Response> { | ||
| 218 | 218 | return redirect("/", { |
| 219 | 219 | cookie: sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url), |
| 220 | 220 | url: ctx.url, |
| 221 | forgeUrl: ctx.app.config.url, | |
| 221 | 222 | flash: { |
| 222 | 223 | message: `You are signed in as ${made.value.username}, your everyday account. Sign in as ${admin.username} when you mean to change the forge.`, |
| 223 | 224 | }, |
| @@ -251,6 +252,7 @@ export async function everydaySwitch(ctx: Ctx): Promise<Response> { | ||
| 251 | 252 | return redirect("/signin", { |
| 252 | 253 | cookie: sessionCookie("", new Date(0), ctx.url, ctx.app.config.url), |
| 253 | 254 | url: ctx.url, |
| 255 | forgeUrl: ctx.app.config.url, | |
| 254 | 256 | flash: { message: `Sign in as ${daily.username}, your everyday account.` }, |
| 255 | 257 | }); |
| 256 | 258 | } |
src/web/pages/site.ts +3 −5
| @@ -52,6 +52,7 @@ import { | ||
| 52 | 52 | } from "../ui.ts"; |
| 53 | 53 | import { existsSync } from "node:fs"; |
| 54 | 54 | import { join } from "node:path"; |
| 55 | import { secureFor } from "../../http/cookie_secure.ts"; | |
| 55 | 56 | import { hasAdmin } from "../../spawn/claim.ts"; |
| 56 | 57 | import { getSignup, type SignupMode } from "../../settings/signup.ts"; |
| 57 | 58 | import { EMAIL_TAKEN_MESSAGE, NAME_RESERVED, USERNAME_TAKEN } from "../../actions/signup_words.ts"; |
| @@ -64,10 +65,7 @@ export function sessionCookie( | ||
| 64 | 65 | ): string { |
| 65 | 66 | // Secure when the request or the forge's own address is https: behind a |
| 66 | 67 | // 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" : ""; | |
| 71 | 69 | return `forge_session=${value}; Path=/; HttpOnly; SameSite=Lax; Expires=${expires.toUTCString()}${secure}`; |
| 72 | 70 | } |
| 73 | 71 | |
| @@ -696,7 +694,7 @@ export async function themeSubmit(ctx: Ctx): Promise<Response> { | ||
| 696 | 694 | const form = await readForm(ctx, undefined, STRANGER_MAX); |
| 697 | 695 | if (form instanceof Response) return form; |
| 698 | 696 | const choice = field(form, "theme"); |
| 699 | const secure = ctx.url.protocol === "https:" ? "; Secure" : ""; | |
| 697 | const secure = secureFor(ctx.url, ctx.app.config.url) ? "; Secure" : ""; | |
| 700 | 698 | const cookie = isThemeName(choice) |
| 701 | 699 | ? `forge_theme=${choice}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly${secure}` |
| 702 | 700 | : `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> { | ||
| 629 | 629 | const set = setFront(ctx.app, ctx.auth.user, next); |
| 630 | 630 | return redirect("/front", { |
| 631 | 631 | url: ctx.url, |
| 632 | forgeUrl: ctx.app.config.url, | |
| 632 | 633 | flash: set.ok |
| 633 | 634 | ? { message: "The front page is updated." } |
| 634 | 635 | : { |
src/web/studio/home_archive.ts +3
| @@ -34,6 +34,7 @@ export async function homeArchive(ctx: Ctx): Promise<Response> { | ||
| 34 | 34 | if (ids.length === 0 || ids.length > MAX_ROWS) |
| 35 | 35 | return redirect("/", { |
| 36 | 36 | url: ctx.url, |
| 37 | forgeUrl: ctx.app.config.url, | |
| 37 | 38 | flash: { tone: "danger", message: "Tick the apps to archive first." }, |
| 38 | 39 | }); |
| 39 | 40 | const op = await applyFleetIds( |
| @@ -47,6 +48,7 @@ export async function homeArchive(ctx: Ctx): Promise<Response> { | ||
| 47 | 48 | if (!op.ok) |
| 48 | 49 | return redirect("/", { |
| 49 | 50 | url: ctx.url, |
| 51 | forgeUrl: ctx.app.config.url, | |
| 50 | 52 | flash: { tone: "danger", message: op.error.message }, |
| 51 | 53 | }); |
| 52 | 54 | return redirect(`/home/archived/${op.value.id}`); |
| @@ -110,6 +112,7 @@ export async function homeArchiveUndo(ctx: Ctx): Promise<Response> { | ||
| 110 | 112 | if (!undone.ok) |
| 111 | 113 | return redirect("/", { |
| 112 | 114 | url: ctx.url, |
| 115 | forgeUrl: ctx.app.config.url, | |
| 113 | 116 | flash: { tone: "danger", message: undone.error.message }, |
| 114 | 117 | }); |
| 115 | 118 | 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> { | ||
| 165 | 165 | } |
| 166 | 166 | return redirect(repoBase(made.value.made.repo), { |
| 167 | 167 | url: ctx.url, |
| 168 | forgeUrl: ctx.app.config.url, | |
| 168 | 169 | flash: { |
| 169 | 170 | message: made.value.existing |
| 170 | 171 | ? `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> { | ||
| 143 | 143 | const after = orderedAsks(ctx.app.db, user).filter((a) => !acted(a)); |
| 144 | 144 | const later = new Set(before.slice(at + 1).map((a) => a.id)); |
| 145 | 145 | 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 }); | |
| 147 | 147 | } |
test/cookie-secure.test.ts added +117
| @@ -0,0 +1,117 @@ | ||
| 1 | /** One rule decides `Secure` on every cookie (#6095): https request, https forge, or https app. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { newApp } from "../src/actions/apps.ts"; | |
| 8 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { secureFor } from "../src/http/cookie_secure.ts"; | |
| 12 | import { clearFlashCookie, flashCookie } from "../src/web/flash.ts"; | |
| 13 | import { cookieHeader } from "../src/http/rp.ts"; | |
| 14 | import { sessionCookie } from "../src/web/pages/site.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | const http = new URL("http://forge.test/x"); | |
| 20 | const https = new URL("https://forge.test/x"); | |
| 21 | ||
| 22 | describe("secureFor", () => { | |
| 23 | test("true for an https request, forge or app; false for none", () => { | |
| 24 | expect(secureFor(https)).toBe(true); | |
| 25 | expect(secureFor(http, "https://forge.test")).toBe(true); | |
| 26 | expect(secureFor(http, "http://forge.test")).toBe(false); | |
| 27 | expect(secureFor(undefined)).toBe(false); | |
| 28 | }); | |
| 29 | ||
| 30 | test("session and flash cookies follow the forge's address, not just the request", () => { | |
| 31 | const at = (forge: string) => [ | |
| 32 | sessionCookie("t", new Date(0), http, forge), | |
| 33 | flashCookie({ message: "hi" }, http, forge), | |
| 34 | clearFlashCookie(http, forge), | |
| 35 | ]; | |
| 36 | for (const c of at("https://forge.test")) expect(c).toContain("; Secure"); | |
| 37 | for (const c of at("http://forge.test")) expect(c).not.toContain("Secure"); | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | describe("the visitor cookie on an app host", () => { | |
| 42 | let dir: string; | |
| 43 | let app: App; | |
| 44 | let servers: Servers; | |
| 45 | let runtime: AppsRuntime; | |
| 46 | beforeAll(async () => { | |
| 47 | dir = await mkdtemp(join(tmpdir(), "forge-secure-")); | |
| 48 | app = createApp({ dataDir: dir, port: 0, host: "forge.test" }); | |
| 49 | servers = await serve(app); | |
| 50 | runtime = new AppsRuntime(app, { domain: "apps.test", scheme: "http", publicPort: servers.port, forgeUrl: servers.url, concurrency: 1 }); | |
| 51 | await runtime.start(); | |
| 52 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 53 | if (!a.ok) throw new Error(a.error); | |
| 54 | const made = await newApp(app, a.value, { name: "notes", description: "Words", visibility: "public" }); | |
| 55 | if (!made.ok) throw new Error(String(made.error)); | |
| 56 | }, 120_000); | |
| 57 | afterAll(async () => { | |
| 58 | await runtime.stop(); | |
| 59 | servers.stop(); | |
| 60 | app.close(); | |
| 61 | await rm(dir, { recursive: true, force: true }); | |
| 62 | }); | |
| 63 | ||
| 64 | const visitor = async (forgeUrl: string) => { | |
| 65 | (runtime.options as { forgeUrl: string }).forgeUrl = forgeUrl; | |
Low: this casts away Writing to Casting away |
||
| 66 | const host = `notes.apps.test:${servers.port}`; | |
| 67 | const res = await fetch(`${servers.url}/__forge/notes`, { headers: { host } }); | |
| 68 | return res.headers.get("set-cookie") ?? ""; | |
| 69 | }; | |
| 70 | ||
| 71 | test("Secure when the forge is https, though the request was http", async () => { | |
| 72 | expect(await visitor("https://forge.test")).toContain("; Secure"); | |
| 73 | }); | |
| 74 | test("not Secure on a plain-http forge", async () => { | |
| 75 | const cookie = await visitor(servers.url); | |
| 76 | expect(cookie).toContain("forge_visitor="); | |
| 77 | expect(cookie).not.toContain("Secure"); | |
| 78 | }); | |
| 79 | }); | |
| 80 | ||
| 81 | describe("the parent, theme and mode cookies follow the same rule", () => { | |
| 82 | let dir: string; | |
| 83 | let app: App; | |
| 84 | let servers: Servers; | |
| 85 | beforeAll(async () => { | |
| 86 | dir = await mkdtemp(join(tmpdir(), "forge-secure-rule-")); | |
| 87 | app = createApp({ dataDir: dir, port: 0, host: "forge.test", url: "https://forge.test" }); | |
| 88 | servers = await serve(app); | |
| 89 | }, 120_000); | |
| 90 | afterAll(async () => { | |
| 91 | servers.stop(); | |
| 92 | app.close(); | |
| 93 | await rm(dir, { recursive: true, force: true }); | |
| 94 | }); | |
| 95 | ||
| 96 | test("the parent sign-in state cookie is Secure on an https forge, though the request was http", () => { | |
| 97 | expect(cookieHeader("s", 600, http, "https://forge.test")).toContain("; Secure"); | |
| 98 | expect(cookieHeader("s", 600, http, "http://forge.test")).not.toContain("Secure"); | |
| 99 | }); | |
| 100 | test("forge_theme is Secure on an https forge, though the request was http", async () => { | |
| 101 | const res = await fetch(`${servers.url}/theme`, { | |
| 102 | method: "POST", | |
| 103 | headers: { origin: "https://forge.test", "content-type": "application/x-www-form-urlencoded" }, | |
| 104 | body: new URLSearchParams({ theme: "dark" }), | |
| 105 | redirect: "manual", | |
| 106 | }); | |
| 107 | const cookie = res.headers.get("set-cookie") ?? ""; | |
| 108 | expect(cookie).toContain("forge_theme=dark"); | |
| 109 | expect(cookie).toContain("; Secure"); | |
| 110 | }); | |
| 111 | test("forge_mode is Secure on an https forge, though the request was http", async () => { | |
| 112 | const res = await fetch(`${servers.url}/?mode=dev`, { redirect: "manual" }); | |
| 113 | const cookie = res.headers.get("set-cookie") ?? ""; | |
| 114 | expect(cookie).toContain("forge_mode="); | |
| 115 | expect(cookie).toContain("; Secure"); | |
| 116 | }); | |
| 117 | }); |
"Every cookie the forge sets" is not true yet:
rp.tscookieHeader,themeSubmitandwithoutModestill decideSecurethemselves. Route them throughsecureFor, or narrow this sentence.