diff --git a/README.md b/README.md index 6ab6e89..f1a29ca 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ auto-merged by `docker compose up`) turns them back off for live editing. | --- | --- | --- | | `PORT` | `3000` | web listen port | | `CACHE_TEMPLATES` | `false` | cache compiled EJS templates (`true` in prod) | +| `SECURE_COOKIES` | `false` | mark our session/CSRF cookies `Secure` (`true` in prod https; off in dev http) | | `REQUIRE_SECURE_SECRETS` | `false` | when `true`, the two secrets must be supplied and differ from the dev throwaways | | `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) | | `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | permission check / write | @@ -512,6 +513,8 @@ src/login.ts completeLogin()/remintSession(): login completion + TTL re- src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4) +src/csrf.ts CSRF for our own POST forms (§4): signed double-submit token — issue/verify, cookie, request gate +src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms) src/context.ts RequestContext handed to handlers + buildContext() src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot src/dashboard.ts buildDashboardModel(): the home "/" People list view model (mock data, wires the §1 helpers) diff --git a/compose.e2e.yml b/compose.e2e.yml index f3c8be3..adcc430 100644 --- a/compose.e2e.yml +++ b/compose.e2e.yml @@ -13,6 +13,7 @@ services: environment: CACHE_TEMPLATES: "true" REQUIRE_SECURE_SECRETS: "false" + SECURE_COOKIES: "false" # the suite hits web over http — Secure cookies wouldn't be stored healthcheck: test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"] interval: 2s diff --git a/compose.override.yml b/compose.override.yml index 61e8a7c..c05a899 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -7,6 +7,7 @@ services: environment: CACHE_TEMPLATES: "false" REQUIRE_SECURE_SECRETS: "false" + SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent volumes: - .:/app - /app/node_modules diff --git a/compose.yml b/compose.yml index 44bed4e..a528df9 100644 --- a/compose.yml +++ b/compose.yml @@ -10,6 +10,7 @@ services: environment: CACHE_TEMPLATES: "true" REQUIRE_SECURE_SECRETS: "true" + SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure # Wait for the services config.ts talks to (kratos + keto) + the one-shot bootstrap # (admin + JWKS seed). Hydra is post-MVP (§6), not in config.ts, so web skips it. depends_on: diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts index 55eab8a..593ffe9 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -102,6 +102,19 @@ test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ expect(offCanvas).toBe(true); }); +test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => { + await page.goto("/"); + // The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit). + const cookie = (await page.context().cookies()).find((c) => c.name === "plainpages_csrf"); + expect(cookie?.value, "GET / issues a plainpages_csrf cookie").toBeTruthy(); + const field = await page.locator('form[action="/logout"] input[name="_csrf"]').getAttribute("value"); + expect(field).toBe(cookie!.value); + + // A POST carrying the cookie but no form token is rejected before any Kratos call. + const res = await page.request.post("/logout", { form: {}, maxRedirects: 0 }); + expect(res.status()).toBe(403); +}); + test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => { const res = await page.goto("/no-such-page"); expect(res?.status()).toBe(404); diff --git a/public/css/styles.css b/public/css/styles.css index 2898c79..d081a44 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -498,6 +498,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */ color: var(--text); background: transparent; border: 0; cursor: pointer; text-align: left; } +.menu-item-form { display: contents; } /* form wraps the Sign-out button without changing layout */ .menu-item:hover { background: var(--surface-2); } .menu-item.danger { color: var(--neg); } .menu-item .ico { color: var(--text-faint); } diff --git a/src/app.test.ts b/src/app.test.ts index 824a591..e588388 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -7,6 +7,7 @@ import { dirname, join } from "node:path"; import { after, before, test, type TestContext } from "node:test"; import { fileURLToPath } from "node:url"; import { createApp } from "./app.ts"; +import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts"; import { can, check, GuardError, requireSession } from "./guards.ts"; import { staticJwks } from "./jwks.ts"; import type { KetoClient } from "./keto-client.ts"; @@ -41,6 +42,13 @@ test("serves the home page: the app-shell People dashboard, filterable via the U assert.match(html, /