/** * 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 requests. * Preserves existing nonce to avoid invalidating tokens the client has cached. * New nonce is generated only on first visit (no cookie) or after TOTP login * (which calls renewCSRFToken). If TOTP is disabled, the nonce is set once * and never changes. */ function csrfCookieMiddleware(req, res, next) { const cookies = parseCookie(req.headers.cookie); const existingNonce = cookies[CSRF_COOKIE_NAME]; // Reuse existing nonce; only generate fresh if no cookie exists yet const csrfNonce = existingNonce || generateToken(); // Store nonce + signature on request so endpoints can access them req.csrfToken = signToken(csrfNonce); req.csrfNonce = csrfNonce; // Only set cookie if it's new (avoids unnecessary Set-Cookie headers) if (!existingNonce) { res.cookie(CSRF_COOKIE_NAME, csrfNonce, { httpOnly: false, // Must be readable by JavaScript for signing secure: req.secure || req.protocol === 'https', sameSite: 'strict', path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year (effectively permanent) }); } next(); } /** * Generate a fresh CSRF nonce and set it on the response. * Called after TOTP login to rotate the token for the new session. * @param {Object} res - Express response object * @param {boolean} secure - Whether to set Secure flag on cookie * @returns {string} The new CSRF signed token */ function renewCSRFToken(res, secure) { const csrfNonce = generateToken(); res.cookie(CSRF_COOKIE_NAME, csrfNonce, { httpOnly: false, secure: !!secure, sameSite: 'strict', path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 }); return signToken(csrfNonce); } /** * 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, renewCSRFToken };