Public: anyone can read this; the crew can change it.
Private pages work with the platform edge turned off: the forge alone issues and checks every session #1521
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1460-private-pages-work-with-the-platform-edg → main 1 commit fork
6 files
changed +119
−10
scripts/gate.ts +1
| @@ -163,6 +163,7 @@ export const GATE: readonly string[] = [ | ||
| 163 | 163 | "test/diff-edge-cases.test.ts", |
| 164 | 164 | "test/diff.test.ts", |
| 165 | 165 | "test/edge-auth.test.ts", |
| 166 | "test/own-session.test.ts", | |
| 166 | 167 | "test/edit-words.test.ts", |
| 167 | 168 | "test/edits-security.test.ts", |
| 168 | 169 | "test/edits.test.ts", |
src/http/api.ts +2 −2
| @@ -1145,7 +1145,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1145 | 1145 | if (!verified.ok) return unauthorized(); |
| 1146 | 1146 | const session = createSession(ctx.app.db, verified.value.id); |
| 1147 | 1147 | return json(publicUser(verified.value), 201, { |
| 1148 | "set-cookie": sessionCookie(session.token, session.expiresAt, ctx.url), | |
| 1148 | "set-cookie": sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url), | |
| 1149 | 1149 | }); |
| 1150 | 1150 | }); |
| 1151 | 1151 | |
| @@ -1155,7 +1155,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1155 | 1155 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden(); |
| 1156 | 1156 | signOutHere(ctx.app, cookieValue(ctx.request, "forge_session")); |
| 1157 | 1157 | return json({}, 200, { |
| 1158 | "set-cookie": sessionCookie("", new Date(0), ctx.url), | |
| 1158 | "set-cookie": sessionCookie("", new Date(0), ctx.url, ctx.app.config.url), | |
| 1159 | 1159 | }); |
| 1160 | 1160 | }); |
| 1161 | 1161 |
src/web/pages/claim.ts +1 −1
| @@ -289,6 +289,6 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 289 | 289 | // Home: where the crew's work shows (its first-login screen is #600). |
| 290 | 290 | return redirect( |
| 291 | 291 | "/", |
| 292 | sessionCookie(session.token, session.expiresAt, ctx.url), | |
| 292 | sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url), | |
| 293 | 293 | ); |
| 294 | 294 | } |
src/web/pages/everyday.ts +2 −2
| @@ -182,7 +182,7 @@ export async function everydayCreate(ctx: Ctx): Promise<Response> { | ||
| 182 | 182 | }); |
| 183 | 183 | const session = createSession(ctx.app.db, made.value.id); |
| 184 | 184 | return redirect("/", { |
| 185 | cookie: sessionCookie(session.token, session.expiresAt, ctx.url), | |
| 185 | cookie: sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url), | |
| 186 | 186 | url: ctx.url, |
| 187 | 187 | flash: { |
| 188 | 188 | message: `You are signed in as ${made.value.username}, your everyday account. Sign in as ${admin.username} when you mean to change the forge.`, |
| @@ -215,7 +215,7 @@ export async function everydaySwitch(ctx: Ctx): Promise<Response> { | ||
| 215 | 215 | if (!daily) return new Response("not found", { status: 404 }); |
| 216 | 216 | signOutHere(ctx.app, cookieValue(ctx.request, "forge_session")); |
| 217 | 217 | return redirect("/signin", { |
| 218 | cookie: sessionCookie("", new Date(0), ctx.url), | |
| 218 | cookie: sessionCookie("", new Date(0), ctx.url, ctx.app.config.url), | |
| 219 | 219 | url: ctx.url, |
| 220 | 220 | flash: { message: `Sign in as ${daily.username}, your everyday account.` }, |
| 221 | 221 | }); |
src/web/pages/site.ts +15 −5
| @@ -54,8 +54,18 @@ import { existsSync } from "node:fs"; | ||
| 54 | 54 | import { join } from "node:path"; |
| 55 | 55 | import { hasAdmin } from "../../spawn/claim.ts"; |
| 56 | 56 | |
| 57 | export function sessionCookie(value: string, expires: Date, url: URL): string { | |
| 58 | const secure = url.protocol === "https:" ? "; Secure" : ""; | |
| 57 | export function sessionCookie( | |
| 58 | value: string, | |
| 59 | expires: Date, | |
| 60 | url: URL, | |
| 61 | forgeUrl?: string, | |
| 62 | ): string { | |
| 63 | // Secure when the request or the forge's own address is https: behind a | |
| 64 | // proxy that ends TLS the request itself arrives as http. | |
| 65 | const secure = | |
| 66 | url.protocol === "https:" || forgeUrl?.startsWith("https://") | |
| 67 | ? "; Secure" | |
| 68 | : ""; | |
| 59 | 69 | return `forge_session=${value}; Path=/; HttpOnly; SameSite=Lax; Expires=${expires.toUTCString()}${secure}`; |
| 60 | 70 | } |
| 61 | 71 | |
| @@ -521,7 +531,7 @@ export async function signinSubmit(ctx: Ctx): Promise<Response> { | ||
| 521 | 531 | const session = createSession(ctx.app.db, verified.value.id); |
| 522 | 532 | return redirect( |
| 523 | 533 | safeNext(ctx.url.searchParams.get("next")) ?? "/", |
| 524 | sessionCookie(session.token, session.expiresAt, ctx.url), | |
| 534 | sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url), | |
| 525 | 535 | ); |
| 526 | 536 | } |
| 527 | 537 | |
| @@ -577,7 +587,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 577 | 587 | const session = createSession(ctx.app.db, created.value.id); |
| 578 | 588 | return redirect( |
| 579 | 589 | safeNext(ctx.url.searchParams.get("next")) ?? "/", |
| 580 | sessionCookie(session.token, session.expiresAt, ctx.url), | |
| 590 | sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url), | |
| 581 | 591 | ); |
| 582 | 592 | } |
| 583 | 593 | |
| @@ -610,7 +620,7 @@ export function logoutSubmit(ctx: Ctx): Response { | ||
| 610 | 620 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) |
| 611 | 621 | return new Response("forbidden", { status: 403 }); |
| 612 | 622 | signOutHere(ctx.app, cookieValue(ctx.request, "forge_session")); |
| 613 | return redirect("/", sessionCookie("", new Date(0), ctx.url)); | |
| 623 | return redirect("/", sessionCookie("", new Date(0), ctx.url, ctx.app.config.url)); | |
| 614 | 624 | } |
| 615 | 625 | |
| 616 | 626 | // ---- new repository ---- |
test/own-session.test.ts added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp, type App } from "../src/app.ts"; | |
| 6 | import { createRepo } from "../src/core/repos.ts"; | |
| 7 | import { listSessions } from "../src/core/sessions.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | ||
| 11 | // #1460: with no platform edge configured the forge alone issues and checks | |
| 12 | // every session, and an edge header never grants access. | |
| 13 | describe("a forge with no platform edge", () => { | |
| 14 | let dataDir: string; | |
| 15 | let app: App; | |
| 16 | let servers: Servers; | |
| 17 | let base: string; | |
| 18 | ||
| 19 | const signIn = async () => { | |
| 20 | const r = await fetch(`${base}/signin`, { | |
| 21 | method: "POST", | |
| 22 | headers: { origin: "https://forge.example" }, | |
| 23 | body: new URLSearchParams({ username: "olive", password: "a-good-passphrase" }), | |
| 24 | redirect: "manual", | |
| 25 | }); | |
| 26 | return r; | |
| 27 | }; | |
| 28 | const get = (path: string, headers: Record<string, string> = {}) => | |
| 29 | fetch(`${base}${path}`, { headers, redirect: "manual" }); | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | dataDir = await mkdtemp(join(tmpdir(), "forge-own-session-")); | |
| 33 | app = createApp({ dataDir, port: 0, url: "https://forge.example" }); | |
| 34 | servers = await serve(app); | |
| 35 | base = servers.url; | |
| 36 | const made = await createUser(app.db, { | |
| 37 | trusted: true, | |
| 38 | username: "olive", | |
| 39 | email: "[email protected]", | |
| 40 | password: "a-good-passphrase", | |
| 41 | adminIfFirst: true, | |
| 42 | }); | |
| 43 | if (!made.ok) throw new Error(made.error); | |
| 44 | const repo = await createRepo(app.db, app.reposRoot, { | |
| 45 | ownerId: made.value.id, | |
| 46 | name: "vault", | |
| 47 | visibility: "private", | |
| 48 | }); | |
| 49 | if (!repo.ok) throw new Error(repo.error); | |
| 50 | }); | |
| 51 | afterAll(async () => { | |
| 52 | servers.stop(); | |
| 53 | app.close(); | |
| 54 | await rm(dataDir, { recursive: true, force: true }); | |
| 55 | }); | |
| 56 | ||
| 57 | test("the session cookie is HttpOnly, SameSite, and Secure for an https forge", async () => { | |
| 58 | const r = await signIn(); | |
| 59 | expect(r.status).toBe(303); | |
| 60 | const cookie = r.headers.get("set-cookie")!; | |
| 61 | expect(cookie).toContain("HttpOnly"); | |
| 62 | expect(cookie).toContain("SameSite=Lax"); | |
| 63 | expect(cookie).toContain("Secure"); | |
| 64 | }); | |
| 65 | ||
| 66 | test("a private page needs the forge's own session", async () => { | |
| 67 | const cookie = (await signIn()).headers.get("set-cookie")!.split(";")[0]!; | |
| 68 | for (const path of ["/olive/vault", "/settings/accounts"]) { | |
| 69 | const anon = await get(path); | |
| 70 | expect(anon.status).not.toBe(200); | |
| 71 | expect(await anon.text()).not.toContain("vault</"); | |
| 72 | const authed = await get(path, { cookie }); | |
| 73 | expect(authed.status).toBe(200); | |
| 74 | } | |
| 75 | expect(await (await get("/olive/vault", { cookie })).text()).toContain("vault"); | |
| 76 | }); | |
| 77 | ||
| 78 | test("edge headers grant nothing when no edge is configured", async () => { | |
| 79 | const forged = { "x-plat-user": "olive", "x-plat-edge-token": "anything" }; | |
| 80 | expect((await get("/olive/vault", forged)).status).not.toBe(200); | |
| 81 | expect((await get("/settings/accounts", forged)).status).not.toBe(200); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a session revoked by its owner is refused on the next request", async () => { | |
| 85 | const cookie = (await signIn()).headers.get("set-cookie")!.split(";")[0]!; | |
| 86 | expect((await get("/olive/vault", { cookie })).status).toBe(200); | |
| 87 | const user = app.db.query<{ id: string }, []>("SELECT id FROM user").get()!; | |
| 88 | const id = listSessions(app.db, user.id, cookie.split("=")[1]).find((s) => s.current)!.id; | |
| 89 | const ended = await fetch(`${base}/settings/accounts/olive/sessions/${id}/delete`, { | |
| 90 | method: "POST", | |
| 91 | headers: { cookie, origin: "https://forge.example" }, | |
| 92 | body: new URLSearchParams({}), | |
| 93 | redirect: "manual", | |
| 94 | }); | |
| 95 | expect(ended.status).toBe(303); | |
| 96 | expect((await get("/olive/vault", { cookie })).status).not.toBe(200); | |
| 97 | }); | |
| 98 | }); |
new above
offline