import type { Req, Res, Next } from '../types/http'; const crypto = require('crypto'); const { logAudit } = require('../services/auditService.cts'); // ───────────────────────────────────────────────────────────────────────────── // CSRF Middleware — double-submit token protection for state-changing routes. // ───────────────────────────────────────────────────────────────────────────── const CSRF_HEADER_NAME = 'x-csrf-token'; const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY !== 'false'; // defaults to true const CSRF_SAME_SITE = process.env.CSRF_SAME_SITE || 'strict'; const CSRF_SECURE = process.env.CSRF_SECURE !== 'false'; // defaults to true const CSRF_COOKIE_NAME = process.env.CSRF_COOKIE_NAME || 'bt_csrf_token'; function generateCsrfToken(): string { return crypto.randomBytes(32).toString('hex'); } /** * Detect HTTPS the same way services/authService.cookieOpts does. */ function requestLooksHttps(req?: Req): boolean { if (!req) return false; if (req.secure) return true; const proto = req.get?.('x-forwarded-proto') || req.headers?.['x-forwarded-proto']; return String(proto || '') .split(',') .map((s: string) => s.trim()) .includes('https'); } /** * Get or create CSRF token for the current session (readable double-submit cookie). */ function getCsrfToken(req: Req, res: Res): string { let token = req.cookies?.[CSRF_COOKIE_NAME]; if (!token) { token = generateCsrfToken(); res.cookie(CSRF_COOKIE_NAME, token, { httpOnly: CSRF_HTTP_ONLY, sameSite: CSRF_SAME_SITE, secure: CSRF_SECURE && requestLooksHttps(req), path: '/', }); } return token; } /** * Validate CSRF token from request (x-csrf-token header or csrf_token body field). */ function validateCsrfToken(req: Req): boolean { const cookieToken = req.cookies?.[CSRF_COOKIE_NAME]; if (!cookieToken) return false; const headerToken = req.headers?.[CSRF_HEADER_NAME]; if (headerToken && headerToken === cookieToken) return true; const bodyToken = req.body?.csrf_token; if (bodyToken && bodyToken === cookieToken) return true; return false; } /** * CSRF middleware — validates tokens for state-changing methods. */ function csrfMiddleware(req: Req, res: Res, next: Next): any { // Exempt the login endpoint only — no session exists yet to hijack. const fullPath = (req.originalUrl || '').split('?')[0]; if (fullPath === '/api/auth/login') { return next(); } if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) { return next(); } if (req.method === 'OPTIONS') { return next(); } if (req.csrfSkip) { return next(); } if (!validateCsrfToken(req)) { logAudit({ user_id: req.user?.id || null, action: 'csrf.failure', ip_address: req.ip, user_agent: req.get('user-agent'), }); return res.status(403).json({ error: 'CSRF token validation failed', message: 'Your session has expired or this request may be fraudulent. Please refresh the page and try again.', code: 'CSRF_INVALID', }); } next(); } /** * Attach CSRF token to response locals for template rendering. */ function csrfTokenProvider(req: Req, res: Res, next: Next): void { res.locals.csrfToken = getCsrfToken(req, res); next(); } module.exports = { CSRF_COOKIE_NAME, CSRF_HEADER_NAME, CSRF_HTTP_ONLY, CSRF_SAME_SITE, CSRF_SECURE, generateCsrfToken, getCsrfToken, validateCsrfToken, csrfMiddleware, csrfTokenProvider, };