/** * CSRF protection via the double-submit cookie pattern. * * The framework sets a readable `wire-csrf` cookie on page loads; the client * echoes it in an `x-csrf-token` header on unsafe requests (the Wire UI form * runtime does this automatically). The server checks header === cookie. A * cross-site attacker can't read the cookie to forge the header, so the request * is rejected — while same-origin requests pass. */ import type { Context, Middleware } from "./context.ts"; export const CSRF_COOKIE = "wire-csrf"; export const CSRF_HEADER = "x-csrf-token"; const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); /** Ensure the CSRF cookie exists (readable by JS) and return its token. */ export function csrfToken(ctx: Context): string { let token = ctx.cookies.get(CSRF_COOKIE); if (!token) { token = crypto.randomUUID().replace(/-/g, ""); // Readable by JS (double-submit needs it) but Secure on HTTPS. ctx.cookies.set(CSRF_COOKIE, token, { sameSite: "Lax", path: "/", secure: ctx.url.protocol === "https:", }); } return token; } /** * Verify an unsafe request's CSRF token against the cookie. Safe methods * (GET/HEAD/OPTIONS) always pass. The token may arrive in the `x-csrf-token` * header or a `_csrf` field already parsed onto `ctx.locals`. */ export function verifyCsrf(ctx: Context): boolean { if (SAFE_METHODS.has(ctx.req.method.toUpperCase())) return true; const cookie = ctx.cookies.get(CSRF_COOKIE); const sent = ctx.req.headers.get(CSRF_HEADER) ?? (ctx.locals._csrf as string | undefined); return !!cookie && !!sent && timingSafeEqual(cookie, sent); } /** * Constant-time string comparison — the running time does not depend on where * the first differing byte is, so an attacker can't time-probe the token. */ function timingSafeEqual(a: string, b: string): boolean { let diff = a.length ^ b.length; const max = Math.max(a.length, b.length); for (let i = 0; i < max; i++) { diff |= a.charCodeAt(i) ^ b.charCodeAt(i); } return diff === 0; } /** Middleware that 403s unsafe requests with a missing/mismatched CSRF token. */ export function csrfProtection(): Middleware { return (ctx, next) => verifyCsrf(ctx) ? next() : new Response("Invalid CSRF token", { status: 403 }); }