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:
@@ -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", () => {
|
||||
|
||||
43
src/app.ts
43
src/app.ts
@@ -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
17
src/body.test.ts
Normal 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
19
src/body.ts
Normal 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"));
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
46
src/csrf.test.ts
Normal 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
58
src/csrf.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user