/** * CSRF protection via the double-submit cookie pattern plus origin/fetch * metadata validation for unsafe requests. */ import type { Context, Middleware } from "./context.ts"; export const CSRF_COOKIE = "wrn-csrf"; export const CSRF_HEADER = "x-csrf-token"; const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); export interface CsrfProtectionOptions { /** Validate Origin when present. Defaults to true. */ verifyOrigin?: boolean; /** Additional exact origins permitted for trusted cross-origin clients. */ trustedOrigins?: string[]; /** Reject Sec-Fetch-Site: cross-site on unsafe requests. Defaults to true. */ verifyFetchMetadata?: boolean; } /** 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, ""); ctx.cookies.set(CSRF_COOKIE, token, { sameSite: "Lax", path: "/", secure: ctx.url.protocol === "https:", }); } return token; } /** Verify an unsafe request's token, origin, and browser fetch metadata. */ export function verifyCsrf(ctx: Context, options: CsrfProtectionOptions = {}): boolean { if (SAFE_METHODS.has(ctx.req.method.toUpperCase())) return true; if (options.verifyFetchMetadata !== false) { const site = ctx.req.headers.get("sec-fetch-site"); if (site === "cross-site") return false; } if (options.verifyOrigin !== false) { const origin = ctx.req.headers.get("origin"); if (origin) { const trusted = new Set([ctx.url.origin, ...(options.trustedOrigins ?? [])]); if (!trusted.has(origin)) return false; } } 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. */ 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 token. */ export function csrfProtection(options: CsrfProtectionOptions = {}): Middleware { return (ctx, next) => verifyCsrf(ctx, options) ? next() : new Response("Invalid CSRF token", { status: 403 }); }