import { H3Event } from 'h3' import crypto from 'crypto' import { getSessionByToken } from './database' import { getAuthCookie } from './auth' /** * Generate a cryptographically secure CSRF token */ export function generateCsrfToken(): string { return crypto.randomBytes(32).toString('hex') } /** * Set CSRF token as readable cookie * Unlike the session cookie, this is NOT httpOnly so JavaScript can read it * This enables the "double submit cookie" pattern: * - Cookie is sent automatically by browser * - JavaScript reads cookie and adds to X-CSRF-Token header * - Server validates both match * - Attacker can't read cookie due to same-origin policy */ export function setCsrfCookie(event: H3Event, csrfToken: string) { setCookie(event, 'csrf_token', csrfToken, { httpOnly: false, // Must be readable by JavaScript for double-submit pattern secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 24, // 24 hours (matches session) path: '/', sameSite: 'lax' }) } /** * Get CSRF token from cookie */ export function getCsrfCookie(event: H3Event): string | undefined { return getCookie(event, 'csrf_token') } /** * Get CSRF token from request header * Frontend should send token in X-CSRF-Token header */ export function getCsrfHeader(event: H3Event): string | undefined { return getHeader(event, 'x-csrf-token') } /** * Clear CSRF cookie on logout */ export function clearCsrfCookie(event: H3Event) { deleteCookie(event, 'csrf_token') } /** * Validate CSRF token * Implements double-submit cookie pattern: * 1. Token in httpOnly cookie (attacker can't read via XSS) * 2. Token in request header (attacker can't forge via CSRF) * 3. Tokens must match AND be tied to valid session */ export async function validateCsrfToken(event: H3Event): Promise { const cookieToken = getCsrfCookie(event) const headerToken = getCsrfHeader(event) // Both tokens must be present if (!cookieToken || !headerToken) { return false } // Tokens must match (constant-time comparison to prevent timing attacks) if (!crypto.timingSafeEqual(Buffer.from(cookieToken), Buffer.from(headerToken))) { return false } // Verify token is tied to valid session const sessionToken = getAuthCookie(event) if (!sessionToken) { return false } const session = getSessionByToken(sessionToken) if (!session || !session.csrf_token) { return false } // Verify the CSRF token matches the session's CSRF token if (!crypto.timingSafeEqual(Buffer.from(session.csrf_token), Buffer.from(cookieToken))) { return false } return true } /** * Check if endpoint should be exempt from CSRF protection * Login and registration endpoints don't need CSRF since they create the session */ export function isCsrfExempt(path: string): boolean { const exemptPaths = [ '/api/auth/login', '/api/auth/register', '/api/auth/verify', // GET request, but included for clarity '/api/auth/forgot-password', // Public endpoint, doesn't require auth '/api/auth/verify-reset-code', // Public endpoint, doesn't require auth '/api/auth/reset-password' // Public endpoint, doesn't require auth ] return exemptPaths.some(exempt => path === exempt) } /** * Middleware helper to require CSRF validation * Returns error response if validation fails */ export async function requireCsrfToken(event: H3Event) { const isValid = await validateCsrfToken(event) if (!isValid) { throw createError({ statusCode: 403, statusMessage: 'Invalid CSRF token' }) } }