/** * CSRF Protection Module * Implements HMAC-signed double-submit cookie pattern for stateless CSRF protection. * The cookie contains a random nonce; the header must carry the HMAC signature * of that nonce computed with a server-side secret. An attacker who can inject * a cookie still cannot forge the matching header without the secret. */ const crypto = require('crypto'); const cryptoUtils = require('./crypto-utils'); const CSRF_TOKEN_LENGTH = 32; const CSRF_COOKIE_NAME = 'dashcaddy_csrf'; const CSRF_HEADER_NAME = 'x-csrf-token'; /** * Generate a cryptographically secure CSRF nonce * @returns {string} Base64URL-encoded random nonce */ function generateToken() { return crypto.randomBytes(CSRF_TOKEN_LENGTH).toString('base64url'); } /** * Compute HMAC signature for a CSRF nonce using the server-side encryption key * @param {string} nonce - The random nonce to sign * @returns {string} Base64URL-encoded HMAC signature */ function signToken(nonce) { const key = cryptoUtils.loadOrCreateKey(); return crypto.createHmac('sha256', key).update(nonce).digest('base64url'); } /** * Parse cookie header string into object * @param {string} cookieHeader - Cookie header value * @returns {Object} Parsed cookies */ function parseCookie(cookieHeader) { if (!cookieHeader) return {}; return cookieHeader.split(';').reduce((cookies, cookie) => { const [name, ...rest] = cookie.trim().split('='); if (name && rest.length > 0) { cookies[name] = rest.join('='); } return cookies; }, {}); } /** * Middleware to set CSRF cookie on all requests. * Always generates a fresh nonce server-side (never trusts client-supplied values). * The cookie holds the nonce; JavaScript must read it and send the HMAC signature * in the x-csrf-token header. The /api/csrf-token endpoint provides the signature. */ function csrfCookieMiddleware(req, res, next) { // Always generate a fresh server-side nonce const csrfNonce = generateToken(); // Store nonce + signature on request so endpoints can access them req.csrfToken = signToken(csrfNonce); req.csrfNonce = csrfNonce; // Set cookie with the nonce (SameSite=Strict for additional protection) res.cookie(CSRF_COOKIE_NAME, csrfNonce, { httpOnly: false, // Must be readable by JavaScript for signing secure: true, sameSite: 'strict', path: '/', maxAge: 24 * 60 * 60 * 1000 // 24 hours }); next(); } /** * Middleware to validate CSRF token on state-changing requests * Validates that the token in the cookie matches the token in the header */ function csrfValidationMiddleware(req, res, next) { const method = req.method.toUpperCase(); // Skip validation for safe methods if (['GET', 'HEAD', 'OPTIONS'].includes(method)) { return next(); } // Skip CSRF validation in test environment if (process.env.NODE_ENV === 'test') { return next(); } // Excluded paths that don't require CSRF validation const excludedPaths = [ '/api/totp/verify', '/api/totp/verify-setup', '/health', '/api/health' ]; // Check if path starts with excluded prefix const isExcluded = excludedPaths.some(path => req.path === path) || req.path.startsWith('/api/auth/gate/'); if (isExcluded) { return next(); } // Get nonce from cookie const cookies = parseCookie(req.headers.cookie); const cookieNonce = cookies[CSRF_COOKIE_NAME]; // Get signed token from header (case-insensitive) const headerToken = req.headers[CSRF_HEADER_NAME] || req.headers[CSRF_HEADER_NAME.toLowerCase()]; // Skip CSRF for API key-authenticated requests (API keys are not sent automatically by browsers) if (req.headers['x-api-key'] || (req.headers.authorization && req.headers.authorization.startsWith('Bearer '))) { return next(); } // Validate both values exist if (!cookieNonce) { console.warn(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}`); return res.status(403).json({ success: false, error: '[DC-100] CSRF token missing', message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.' }); } if (!headerToken) { console.warn(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}`); return res.status(403).json({ success: false, error: '[DC-100] CSRF token missing', message: 'CSRF token not provided in request headers. Please refresh the page (Ctrl+Shift+R) and try again.' }); } // Validate that the header token is the correct HMAC signature of the cookie nonce try { const expectedSig = signToken(cookieNonce); const expectedBuffer = Buffer.from(expectedSig, 'base64url'); const headerBuffer = Buffer.from(headerToken, 'base64url'); if (expectedBuffer.length !== headerBuffer.length) { throw new Error('Token length mismatch'); } if (!crypto.timingSafeEqual(expectedBuffer, headerBuffer)) { throw new Error('Token mismatch'); } // Signature valid — request is authentic next(); } catch (err) { console.warn(`[CSRF] Invalid CSRF token: ${method} ${req.path} from ${req.ip} - ${err.message}`); return res.status(403).json({ success: false, error: '[DC-101] CSRF token invalid', message: 'CSRF token validation failed. Please refresh the page (Ctrl+Shift+R) and try again.' }); } } module.exports = { CSRF_TOKEN_LENGTH, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generateToken, signToken, parseCookie, csrfCookieMiddleware, csrfValidationMiddleware };