/** * 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 { errorResponse } = require('../utils/responses'); 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; }, {}); } /** * Create CSRF middleware with cookie domain support. * When a TLD (e.g. ".sami") is provided, cookies are set with Domain=.sami * so they are shared across all subdomains for forward_auth SSO. * @param {Object} [options] * @param {string} [options.cookieDomain] - e.g. ".sami" to share cookies across subdomains * @returns {{ csrfCookieMiddleware: Function, renewCSRFToken: Function }} */ function createCSRFMiddleware(options = {}) { const { cookieDomain } = options; /** * 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) { const cookieOpts = { 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) }; if (cookieDomain) cookieOpts.domain = cookieDomain; res.cookie(CSRF_COOKIE_NAME, csrfNonce, cookieOpts); } 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(); const cookieOpts = { httpOnly: false, secure: !!secure, sameSite: 'strict', path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 }; if (cookieDomain) cookieOpts.domain = cookieDomain; res.cookie(CSRF_COOKIE_NAME, csrfNonce, cookieOpts); return signToken(csrfNonce); } return { csrfCookieMiddleware, renewCSRFToken }; } /** * 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 // Note: probe endpoints (/health, /health/live, /health/ready, /healthz, // /readyz) are GET-only so they're already excluded by the safe-methods // check above. Listed here for explicit safety in case any of them ever // accept a POST in the future. const excludedPaths = [ '/api/v1/totp/verify', '/api/v1/totp/verify-setup', '/api/v1/totp/setup', // DC-046 pluggable auth endpoints — public login endpoints, same // exemption rationale as the legacy /totp/* paths: a user with no // session cookie yet cannot present a CSRF token, so the login flow // must be exempt. CSRF protection on the auth boundary is enforced // by the SameSite=Lax cookie attribute instead. :provider matches // any registered AuthProvider (totp today, email after DC-047). '/api/v1/auth/login/:provider/verify', '/api/v1/auth/login/:provider/initiate', '/api/v1/auth/disable/:provider', // DC-048: invite redemption is the same exemption as login verify — // the user has no session cookie yet (they just clicked an email link). // CSRF on this boundary is enforced by SameSite=Lax instead. '/api/v1/auth/invites/:token/accept', // DC-053: share-link subscribe + Tailscale redeem originate from the // public share page (cross-origin). The token itself is the proof; CSRF // is bounded by the token's TTL + scope. Same model as invite accept. '/api/v1/share/:token/subscribe', '/api/v1/share/:token/redeem-tailscale', // DC-055: Stripe Checkout session creation. Browsers hit this from the // public pricing page (cross-origin from any *.sami subdomain that // serves it); no session cookie exists yet, so a CSRF token can't be // anchored. SameSite=Lax on the session cookie doesn't apply (none // exists). Threat model: an attacker who can trigger checkout sessions // can only force a customer to land on Stripe's hosted page — they // can't extract money. Stripe's session id is single-use and tied to a // chosen price; reusing it requires Stripe's webhook secret. '/api/v1/billing/checkout', // DC-057: success page polls this from the customer's browser after // Stripe redirects them back. Same CSRF argument as above (no session // cookie exists yet) — and the response is the customer's own license // code, not anything an attacker can exploit by triggering the lookup. '/api/v1/billing/lookup/:sessionId', '/health', '/health/live', '/health/ready', '/healthz', '/readyz', // Machine-to-machine: publishing host POSTs here with its own shared-secret // header (X-DashCaddy-Notify-Secret) — browsers never reach this endpoint. '/api/v1/system/update-notify' ]; const isExcluded = excludedPaths.some(path => { if (req.path === path) return true; // Allow `:param` placeholders to match any single segment. Pre-existing // bug — literal ':token' never matched real tokens — fixed under DC-053. if (path.includes(':')) { const pattern = '^' + path.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '[^/]+') + '$'; return new RegExp(pattern).test(req.path); } return false; }) || req.path.startsWith('/api/v1/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(); } // DC-058: differentiate "browser auto-retry" from "real probe" using the // X-CSRF-Token header as a signal. The dashboard JS in status/js/globals.js // secureFetch() pre-fetches /api/v1/csrf-token (which sets the CSRF cookie // via csrfCookieMiddleware) before posting; if the GET raced with container // restart OR the user cleared cookies mid-session, the POST can arrive with // a header but no cookie. secureFetch catches the 403 and auto-retries // with a fresh token (lines 225-238 of globals.js). For these "has header // but no cookie" misses, tag the log line [CSRF-debug] — operators can // grep them out as expected noise. A request with NEITHER cookie NOR // header (curl probe, exploit scanner, broken client) keeps the louder // [CSRF] tag. if (!cookieNonce) { const isLikelyBrowserAutoRetry = !!headerToken; const tag = isLikelyBrowserAutoRetry ? '[CSRF-debug]' : '[CSRF]'; process.stderr.write(`${tag} Missing CSRF cookie: ${method} ${req.path} from ${req.ip}` + (isLikelyBrowserAutoRetry ? ' (browser auto-retry — header present, expect self-heal)' : '') + '\n'); return errorResponse(res, 403, '[DC-100] CSRF token missing', { message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.' }); } // Cookie present but no header — a real browser POST always sends both, so // header-less is suspicious (curl probe with manual cookie, misconfigured // client). Keep WARN level. if (!headerToken) { process.stderr.write(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}\n`); return errorResponse(res, 403, '[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) { process.stderr.write(`[CSRF] Invalid CSRF token: ${method} ${req.path} from ${req.ip} - ${err.message}\n`); return errorResponse(res, 403, '[DC-101] CSRF token invalid', { message: 'CSRF token validation failed. Please refresh the page (Ctrl+Shift+R) and try again.' }); } } // Default instance (no domain) for backward compatibility with tests const defaultInstance = createCSRFMiddleware(); module.exports = { CSRF_TOKEN_LENGTH, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generateToken, signToken, parseCookie, createCSRFMiddleware, csrfValidationMiddleware, // Default instance exports for backward compat csrfCookieMiddleware: defaultInstance.csrfCookieMiddleware, renewCSRFToken: defaultInstance.renewCSRFToken };