import crypto from 'node:crypto'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import fp from 'fastify-plugin'; import { isProd, env } from '../env'; import { SESSION_COOKIE } from './sessions'; export const CSRF_COOKIE = 'csrf'; export const CSRF_HEADER = 'x-csrf-token'; const TOKEN_BYTES = 32; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); // Routes that legitimately bypass CSRF — they receive their own auth (signature check) // or have no session yet, so a CSRF attack against them is meaningless. const CSRF_EXEMPT_PREFIXES = ['/api/auth/', '/api/contact', '/api/webhooks/', '/api/tool-usage']; export function generateCsrfToken(): string { return crypto.randomBytes(TOKEN_BYTES).toString('base64url'); } function constantTimeEqual(a: string, b: string): boolean { const ab = Buffer.from(a); const bb = Buffer.from(b); if (ab.length !== bb.length) return false; return crypto.timingSafeEqual(ab, bb); } declare module 'fastify' { interface FastifyInstance { setCsrfCookie: (reply: FastifyReply, token: string) => void; clearCsrfCookie: (reply: FastifyReply) => void; } } async function plugin(app: FastifyInstance) { app.decorate('setCsrfCookie', (reply: FastifyReply, token: string) => { reply.setCookie(CSRF_COOKIE, token, { path: '/', httpOnly: false, // intentional — JS reads this and echoes it as a header secure: isProd, sameSite: 'lax', domain: env.COOKIE_DOMAIN || undefined, }); }); app.decorate('clearCsrfCookie', (reply: FastifyReply) => { reply.clearCookie(CSRF_COOKIE, { path: '/', secure: isProd, sameSite: 'lax', domain: env.COOKIE_DOMAIN || undefined, }); }); // Auto-mint a CSRF token whenever an authenticated session exists but no CSRF cookie is set. // This makes the protection self-bootstrapping after sessions created before CSRF was enabled. app.addHook('onRequest', async (req, reply) => { if (!req.cookies?.[SESSION_COOKIE]) return; if (req.cookies?.[CSRF_COOKIE]) return; const token = generateCsrfToken(); app.setCsrfCookie(reply, token); req.cookies = { ...req.cookies, [CSRF_COOKIE]: token }; }); // Verify CSRF on every state-changing request that has a session cookie. app.addHook('preHandler', async (req: FastifyRequest, reply: FastifyReply) => { if (SAFE_METHODS.has(req.method)) return; if (!req.cookies?.[SESSION_COOKIE]) return; // unauthenticated → nothing to protect const url = req.routeOptions.url || req.url; if (CSRF_EXEMPT_PREFIXES.some((p) => url.startsWith(p))) return; const cookie = req.cookies?.[CSRF_COOKIE]; const header = (req.headers[CSRF_HEADER] as string | undefined) ?? ''; if (!cookie || !header || !constantTimeEqual(cookie, header)) { return reply.code(403).send({ error: 'csrf_failed' }); } }); } export const csrfPlugin = fp(plugin, { name: 'csrf', dependencies: ['auth'] });