Secure cookie flags + CSRF for our own POST forms (todo §4); SECURE_COOKIES toggle on session/CSRF cookies; csrf.ts signed double-submit token + body.ts form reader; logout is now a CSRF-guarded POST form

This commit is contained in:
2026-06-18 11:12:32 +02:00
parent dec55f85a6
commit 4b2173cb84
21 changed files with 241 additions and 26 deletions

View File

@@ -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, /<footer class="pager"/);
assert.match(html, /Avery Kline/); // a mock person on page 1
// The Sign-out POST form carries a CSRF token matching the Set-Cookie issued for the page (§4).
const csrfCookie = (res.headers.get("set-cookie") ?? "").match(/plainpages_csrf=([^;]+)/)?.[1];
assert.ok(csrfCookie, "GET / issues a CSRF cookie");
assert.match(res.headers.get("set-cookie") ?? "", /plainpages_csrf=[^;]+;.*HttpOnly/);
assert.match(html, /<form class="menu-item-form" method="post" action="\/logout">/);
assert.match(html, new RegExp(`name="_csrf" value="${csrfCookie!.replace(/[.]/g, "\\.")}"`));
// A search query filters server-side: a no-match query drops every row.
const empty = await fetch(base + "/?q=zzz-no-such-person");
assert.doesNotMatch(await empty.text(), /Avery Kline/);
@@ -409,25 +417,35 @@ test("login completion with no Kratos session redirects to /login and sets no co
assert.equal(res.headers.get("set-cookie"), null);
});
test("logout: bounces to Kratos to revoke the session and clears our JWT cookie; no session → /login", async (t) => {
test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clears our JWT; bad token → 403", async (t) => {
const logoutUrl = "http://127.0.0.1:4433/self-service/logout?token=lt";
const kratos: KratosPublic = { ...mockKratos(async () => { throw new Error("unused"); }), createLogoutFlow: async (o) => (o?.cookie ? { logoutToken: "lt", logoutUrl } : null) };
const app = createApp({ kratos });
// Real Kratos keys off its own session cookie (plainpages_session), not our always-present CSRF cookie.
const kratos: KratosPublic = { ...mockKratos(async () => { throw new Error("unused"); }), createLogoutFlow: async (o) => (o?.cookie?.includes("plainpages_session") ? { logoutToken: "lt", logoutUrl } : null) };
const csrfSecret = "logout-secret";
const app = createApp({ csrfSecret, kratos });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const token = issueCsrfToken(csrfSecret);
const post = (cookie: string, body: string) =>
fetch(url + "/logout", { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie }, method: "POST", redirect: "manual" });
// Active session → redirect to Kratos' logout URL (it revokes + clears plainpages_session, then → /login).
const out = await fetch(url + "/logout", { headers: { cookie: `${SESSION_COOKIE}=x; plainpages_session=s` }, redirect: "manual" });
// Valid double-submit (cookie token === form token) + active session → Kratos logout URL, JWT cleared.
const out = await post(`${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=x; plainpages_session=s`, `_csrf=${token}`);
assert.equal(out.status, 303);
assert.equal(out.headers.get("location"), logoutUrl);
assert.match(out.headers.get("set-cookie") ?? "", /^plainpages_jwt=;.*Max-Age=0/);
assert.match(out.headers.getSetCookie().join("\n"), /plainpages_jwt=;.*Max-Age=0/);
// No active Kratos session → clear our cookie and land on /login ourselves.
const none = await fetch(url + "/logout", { redirect: "manual" });
const none = await post(`${CSRF_COOKIE}=${token}`, `_csrf=${token}`);
assert.equal(none.status, 303);
assert.equal(none.headers.get("location"), "/login");
assert.match(none.headers.get("set-cookie") ?? "", /^plainpages_jwt=;.*Max-Age=0/);
assert.match(none.headers.getSetCookie().join("\n"), /plainpages_jwt=;.*Max-Age=0/);
// Missing field and a forged token are both refused (no Kratos call, no cookie cleared).
assert.equal((await post(`${CSRF_COOKIE}=${token}`, "")).status, 403);
assert.equal((await post(`${CSRF_COOKIE}=${token}`, "_csrf=forged.sig")).status, 403);
assert.equal((await post("", `_csrf=${token}`)).status, 403); // no cookie to match
});
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {

View File

@@ -1,8 +1,11 @@
import { randomBytes } from "node:crypto";
import { createServer, type Server, type ServerResponse } from "node:http";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
import { readFormBody } from "./body.ts";
import { buildContext, type User } from "./context.ts";
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts";
import { buildDashboardModel } from "./dashboard.ts";
import { PLUGINS_DIR } from "./discovery.ts";
import { GuardError } from "./guards.ts";
@@ -27,6 +30,7 @@ export interface AppOptions {
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
// Off by default so edits show live; the app itself never inspects the environment.
cache?: boolean;
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4)
@@ -35,12 +39,15 @@ export interface AppOptions {
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
publicDir?: string;
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
viewsDir?: string;
}
export function createApp(options: AppOptions = {}): Server {
const authOptions = options.auth ?? {};
const cache = options.cache ?? false;
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
const secureCookies = options.secureCookies ?? false;
const jwks = options.jwks;
const keto = options.keto;
const kratos = options.kratos;
@@ -90,11 +97,14 @@ export function createApp(options: AppOptions = {}): Server {
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
user = auth.user;
if (!user && auth.expired && keto && kratos && kratosAdmin) {
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie);
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie, { secure: secureCookies });
user = reminted.user;
res.setHeader("set-cookie", reminted.setCookie);
res.appendHeader("set-cookie", reminted.setCookie);
}
}
// CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint
// one (the form page below Set-Cookies it). Verified on our own state-changing routes (§4).
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
const ctx = buildContext(req, res, { user }); // base context (no route params yet); reused for onRequest
// Plugin onRequest hooks run before routing and may short-circuit the request.
@@ -128,7 +138,8 @@ export function createApp(options: AppOptions = {}): Server {
if (!flowId) {
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
const { flow, setCookie } = await kratos.initBrowserFlow(flowType, cookie ? { cookie } : {});
res.writeHead(303, { location: `${pathname}?flow=${flow.id}`, ...(setCookie.length ? { "set-cookie": setCookie } : {}) }).end();
if (setCookie.length) res.appendHeader("set-cookie", setCookie);
res.writeHead(303, { location: `${pathname}?flow=${flow.id}` }).end();
return;
}
try {
@@ -152,24 +163,32 @@ export function createApp(options: AppOptions = {}): Server {
res.writeHead(303, { location: "/login" }).end();
return;
}
// secure: off in dev http; the §9 cookie hardening toggles it on for prod.
res.writeHead(303, { location: "/", "set-cookie": sessionCookie(completed.jwt) }).end();
res.appendHeader("set-cookie", sessionCookie(completed.jwt, { secure: secureCookies }));
res.writeHead(303, { location: "/" }).end();
return;
}
// Logout: clear our local JWT and revoke the Kratos session. Kratos' own cookie lives on
// its origin, so we can't clear it here — redirect the browser to Kratos' logout URL (it
// revokes the session, clears plainpages_session, then lands on /login per kratos.yml).
// No active session ⇒ just clear our cookie and go to /login.
if (pathname === "/logout" && (method === "GET" || method === "HEAD") && kratos) {
// Logout: a state change, so a CSRF-guarded POST (the shell submits a form, not a GET link).
// Clear our local JWT and revoke the Kratos session — Kratos' own cookie lives on its origin,
// so redirect to its logout URL (it revokes the session, clears plainpages_session, then lands
// on /login per kratos.yml). No active session ⇒ just clear our cookie and go to /login.
if (pathname === "/logout" && method === "POST" && kratos) {
const form = await readFormBody(req);
if (!verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
const flow = await kratos.createLogoutFlow(req.headers.cookie ? { cookie: req.headers.cookie } : {});
res.writeHead(303, { location: flow?.logoutUrl ?? "/login", "set-cookie": clearSessionCookie() }).end();
res.appendHeader("set-cookie", clearSessionCookie({ secure: secureCookies }));
res.writeHead(303, { location: flow?.logoutUrl ?? "/login" }).end();
return;
}
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
// Roles from the verified JWT (anonymous ⇒ []); branding/override come from config/menu.ts.
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu) }));
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu, csrf.token) }));
return;
}

17
src/body.test.ts Normal file
View File

@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import type { IncomingMessage } from "node:http";
import { Readable } from "node:stream";
import { test } from "node:test";
import { readFormBody } from "./body.ts";
const reqOf = (body: string): IncomingMessage => Readable.from([Buffer.from(body)]) as unknown as IncomingMessage;
test("readFormBody parses urlencoded fields, handles an empty body, and caps the size", async () => {
const form = await readFormBody(reqOf("_csrf=abc.def&name=Sam+Rivers"));
assert.equal(form.get("_csrf"), "abc.def");
assert.equal(form.get("name"), "Sam Rivers");
assert.equal([...(await readFormBody(reqOf("")))].length, 0); // empty body ⇒ no fields, no throw
await assert.rejects(() => readFormBody(reqOf("x".repeat(50)), { limit: 10 }), /limit/);
});

19
src/body.ts Normal file
View File

@@ -0,0 +1,19 @@
// Read an application/x-www-form-urlencoded request body (todo §4). Our own POST forms are
// tiny, so cap the size and reject anything larger rather than buffer unbounded. Consumes the
// stream once; never throws on an empty body. The CSRF gate + §5 admin forms read fields here.
import type { IncomingMessage } from "node:http";
const DEFAULT_LIMIT = 1024 * 1024; // 1 MiB
export async function readFormBody(req: IncomingMessage, options: { limit?: number } = {}): Promise<URLSearchParams> {
const limit = options.limit ?? DEFAULT_LIMIT;
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
const buf = chunk as Buffer;
size += buf.length;
if (size > limit) throw new Error("request body exceeds limit");
chunks.push(buf);
}
return new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
}

View File

@@ -14,6 +14,7 @@ test("loads dev defaults when the environment is empty", () => {
const c = loadConfig({});
assert.equal(c.port, 3000);
assert.equal(c.cacheTemplates, false);
assert.equal(c.secureCookies, false); // dev runs http; prod sets SECURE_COOKIES=true
assert.equal(c.kratosPublicUrl, "http://kratos:4433");
assert.equal(c.kratosAdminUrl, "http://kratos:4434");
assert.equal(c.ketoReadUrl, "http://keto:4466");
@@ -43,6 +44,7 @@ test("JWT issuer/audience are optional: unset by default, pinned from the env",
test("parses explicit boolean toggles and rejects non-boolean values", () => {
assert.equal(loadConfig({ CACHE_TEMPLATES: "true" }).cacheTemplates, true);
assert.equal(loadConfig({ CACHE_TEMPLATES: "false" }).cacheTemplates, false);
assert.equal(loadConfig({ SECURE_COOKIES: "true" }).secureCookies, true);
assert.throws(() => loadConfig({ CACHE_TEMPLATES: "yes" }), /CACHE_TEMPLATES/);
});

View File

@@ -20,6 +20,7 @@ export interface Config {
kratosAdminUrl: string;
kratosPublicUrl: string;
port: number;
secureCookies: boolean;
}
type Env = Record<string, string | undefined>;
@@ -88,5 +89,7 @@ export function loadConfig(env: Env = process.env): Config {
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
port: readPort(env),
// Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it.
secureCookies: readBool(env, "SECURE_COOKIES", false),
};
}

46
src/csrf.test.ts Normal file
View File

@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { csrfCookie, ensureCsrfToken, issueCsrfToken, verifyCsrfRequest, verifyCsrfToken } from "./csrf.ts";
const SECRET = "test-csrf-secret";
test("issued tokens are signed: round-trip verifies; tamper/wrong-secret/garbage fail", () => {
const token = issueCsrfToken(SECRET);
assert.match(token, /^[\w-]+\.[\w-]+$/); // <nonce>.<hmac>, base64url
assert.ok(verifyCsrfToken(SECRET, token));
assert.equal(verifyCsrfToken(SECRET, token.replace(/.$/, (c) => (c === "a" ? "b" : "a"))), false); // tampered mac
assert.equal(verifyCsrfToken("other-secret", token), false);
assert.equal(verifyCsrfToken(SECRET, undefined), false);
assert.equal(verifyCsrfToken(SECRET, "nodot"), false);
assert.notEqual(issueCsrfToken(SECRET), issueCsrfToken(SECRET)); // random nonce each time
});
test("ensureCsrfToken reuses a valid cookie token, mints a fresh one when absent/invalid", () => {
const token = issueCsrfToken(SECRET);
const reused = ensureCsrfToken(`plainpages_csrf=${token}; other=x`, SECRET);
assert.deepEqual(reused, { fresh: false, token });
const minted = ensureCsrfToken(undefined, SECRET);
assert.equal(minted.fresh, true);
assert.ok(verifyCsrfToken(SECRET, minted.token));
const bad = ensureCsrfToken("plainpages_csrf=forged.value", SECRET);
assert.equal(bad.fresh, true); // a forged cookie is replaced, not trusted
});
test("csrfCookie builds the HttpOnly/Lax cookie; Secure is opt-in", () => {
assert.match(csrfCookie("tok"), /^plainpages_csrf=tok;.*HttpOnly; SameSite=Lax$/);
assert.match(csrfCookie("tok", { secure: true }), /; SameSite=Lax; Secure$/);
});
test("verifyCsrfRequest requires a genuine cookie that the submitted field echoes (double-submit)", () => {
const token = issueCsrfToken(SECRET);
const cookieHeader = `plainpages_csrf=${token}`;
assert.ok(verifyCsrfRequest({ cookieHeader, secret: SECRET, submitted: token }));
assert.equal(verifyCsrfRequest({ cookieHeader: undefined, secret: SECRET, submitted: token }), false); // no cookie
assert.equal(verifyCsrfRequest({ cookieHeader, secret: SECRET, submitted: null }), false); // no field
assert.equal(verifyCsrfRequest({ cookieHeader, secret: SECRET, submitted: issueCsrfToken(SECRET) }), false); // field ≠ cookie
assert.equal(verifyCsrfRequest({ cookieHeader: "plainpages_csrf=forged.v", secret: SECRET, submitted: "forged.v" }), false); // matching but unsigned
});

58
src/csrf.ts Normal file
View File

@@ -0,0 +1,58 @@
// CSRF protection for our own POST forms (todo §4). Stateless signed double-submit token:
// the token is `<nonce>.<HMAC(secret, nonce)>`, set as a cookie *and* echoed in a hidden form
// field. A request passes iff the cookie is a genuine signature (can't be forged without the
// secret) and the submitted field equals it. SameSite=Lax already blocks the cross-site POST
// from sending the cookie; the signature + double-submit defend the rest. Kratos' own flows
// carry Kratos' CSRF token — this guards only the routes we handle.
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { parseCookies, serializeCookie } from "./cookie.ts";
export const CSRF_COOKIE = "plainpages_csrf";
export const CSRF_FIELD = "_csrf"; // hidden input name forms submit the token under
const MAX_AGE = 60 * 60 * 24 * 30; // 30d, mirrors the session cookie so the token survives restarts
const NONCE_BYTES = 18;
function sign(secret: string, nonce: string): string {
return createHmac("sha256", secret).update(nonce).digest("base64url");
}
function timingEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
export function issueCsrfToken(secret: string): string {
const nonce = randomBytes(NONCE_BYTES).toString("base64url");
return `${nonce}.${sign(secret, nonce)}`;
}
// True iff `token` is a `<nonce>.<hmac>` we signed (self-validating — no server state).
export function verifyCsrfToken(secret: string, token: string | null | undefined): boolean {
if (!token) return false;
const dot = token.indexOf(".");
if (dot <= 0) return false;
return timingEqual(token.slice(dot + 1), sign(secret, token.slice(0, dot)));
}
// The token to embed in this request's forms: reuse a genuine cookie token, else mint one
// (`fresh` ⇒ the caller must Set-Cookie it). Reusing keeps every open tab/form on one token.
export function ensureCsrfToken(cookieHeader: string | undefined, secret: string): { fresh: boolean; token: string } {
const existing = parseCookies(cookieHeader)[CSRF_COOKIE];
if (existing && verifyCsrfToken(secret, existing)) return { fresh: false, token: existing };
return { fresh: true, token: issueCsrfToken(secret) };
}
export function csrfCookie(token: string, options: { secure?: boolean } = {}): string {
return serializeCookie(CSRF_COOKIE, token, { httpOnly: true, maxAge: MAX_AGE, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) });
}
// Gate a state-changing request: the cookie must be a genuine signed token and the submitted
// field must equal it. Fail-closed on any missing/forged/mismatched part.
export function verifyCsrfRequest(args: { cookieHeader: string | undefined; secret: string; submitted: string | null | undefined }): boolean {
const cookieToken = parseCookies(args.cookieHeader)[CSRF_COOKIE];
if (!cookieToken || !args.submitted) return false;
if (!verifyCsrfToken(args.secret, cookieToken)) return false;
return timingEqual(cookieToken, args.submitted);
}

View File

@@ -14,6 +14,8 @@ test("dashboard default: page 1, mock data, nav + shell wired", () => {
const m = buildDashboardModel(new URL("http://x/"));
assert.equal(m.shell.title, "People");
assert.equal(m.shell.csrfToken, ""); // default empty; app.ts passes the per-request token
assert.equal(buildDashboardModel(new URL("http://x/"), [], undefined, "tok.sig").shell.csrfToken, "tok.sig");
assert.ok(m.nav.length > 0); // composeNav produced a tree
assert.equal(col0(m).label, "Name");
assert.equal(m.pagination.summary.total, 30); // full mock dataset

View File

@@ -77,7 +77,7 @@ function href(state: State, overrides: Partial<State> = {}): string {
return qs ? `?${qs}` : "?";
}
export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = [], menu: MenuConfig = DEFAULT_MENU) {
export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = [], menu: MenuConfig = DEFAULT_MENU, csrfToken = "") {
const query = parseListQuery(url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const status = query.filters.status?.[0] ?? "all";
const team = query.filters.team?.[0] ?? "";
@@ -111,6 +111,7 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles:
...(menu.branding.sub != null ? { sub: menu.branding.sub } : {}),
},
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
csrfToken, // hidden field for the shell's Sign-out POST form (§4)
...(menu.branding.theme != null ? { theme: menu.branding.theme } : {}),
title: "People",
user: { email: "sam.rivers@example.com", initials: "SR", name: "Sam Rivers" }, // demo until §4

View File

@@ -25,12 +25,14 @@ await runBootHooks(plugins); // plugin onBoot — after discovery, before listen
const server = createApp({
auth: { audience: config.jwtAudience, issuer: config.jwtIssuer },
cache: config.cacheTemplates,
csrfSecret: config.csrfSecret,
jwks,
keto,
kratos,
kratosAdmin,
menu,
plugins,
secureCookies: config.secureCookies,
}).listen(config.port, () => {
console.log(`Listening on http://localhost:${config.port}`);
});

View File

@@ -11,6 +11,7 @@ test("app shell renders sidebar, topbar and the content slot", async () => {
const html = await render({
title: "People",
brand: { name: "Acme Console", sub: "v2" },
csrfToken: "tok.sig",
nav: '<a id="nav-marker" href="/x">Overview</a>',
body: '<section id="body-marker">page</section>',
actions: '<button id="action-marker">Add</button>',
@@ -30,8 +31,9 @@ test("app shell renders sidebar, topbar and the content slot", async () => {
assert.match(html, /<section id="body-marker">page<\/section>/); // content slot
assert.match(html, /<button id="action-marker"/); // topbar actions slot
// Sign out is wired to the logout route (the side-footer profile menu).
assert.match(html, /<a class="menu-item danger" href="\/logout">/);
// Sign out is a CSRF-guarded POST form (state change, not a GET link), carrying the token.
assert.match(html, /<form class="menu-item-form" method="post" action="\/logout">/);
assert.match(html, /<input type="hidden" name="_csrf" value="tok\.sig" \/>/);
// Branding, document title, and the inlined icon sprite (so <use> resolves).
assert.match(html, /Acme Console/);