/** * Middleware Configuration Module * Extracts the entire middleware stack from server.js (Phase 3 refactoring) * * Configures: CORS, Helmet, body parser, compression, CSRF, request IDs, * metrics/access logging, Tailscale auth, TOTP sessions, JWT/API key auth, * rate limiting, and audit logging. */ const express = require('express'); const cors = require('cors'); const helmet = require('helmet'); const compression = require('compression'); const crypto = require('crypto'); const rateLimit = require('express-rate-limit'); const { csrfCookieMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('./csrf-protection'); const { RATE_LIMITS, LIMITS, APP } = require('./constants'); const { CACHE_CONFIGS, createCache } = require('./cache-config'); /** * Configure all middleware on the Express app. * * @param {import('express').Express} app * @param {Object} deps - Dependencies from server.js * @returns {Object} Items that routes and ctx need */ module.exports = function configureMiddleware(app, { siteConfig, totpConfig, tailscaleConfig, metrics, auditLogger, authManager, log, cryptoUtils, isValidContainerId, isTailscaleIP, getTailscaleStatus }) { // ── Container ID param validation ── app.param('id', (req, res, next, id) => { if (req.path.includes('/containers/') && !isValidContainerId(id)) { return res.status(400).json({ success: false, error: 'Invalid container ID' }); } next(); }); // ── CORS (#9: origins derived from config) ── const corsOrigins = [`https://${siteConfig.dashboardHost}`]; if (process.env.NODE_ENV !== 'production') corsOrigins.push('http://localhost:3001'); app.use(cors({ origin: corsOrigins, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], credentials: true })); // ── Security headers with Helmet ── app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'"], scriptSrc: ["'self'"], imgSrc: ["'self'", "data:", "https:"], connectSrc: ["'self'"], fontSrc: ["'self'", "data:"], objectSrc: ["'none'"], mediaSrc: ["'self'"], frameSrc: ["'none'"] } }, crossOriginEmbedderPolicy: false, crossOriginResourcePolicy: { policy: "cross-origin" } })); // ── Trust proxy (one hop — Caddy) ── app.set('trust proxy', 1); // ── JSON body parser (default 1MB limit) ── app.use(express.json({ limit: LIMITS.BODY_DEFAULT })); // ── Compress responses (gzip/brotli) ── app.use(compression()); // ── CSRF Protection ── app.use(csrfCookieMiddleware); app.use(csrfValidationMiddleware); // ── Request ID ── app.use((req, res, next) => { req.id = crypto.randomUUID(); res.setHeader('X-Request-ID', req.id); next(); }); // ── Metrics + access log ── app.use((req, res, next) => { const start = Date.now(); res.on('finish', () => { const duration = Date.now() - start; metrics.recordRequest(req.method, req.path, res.statusCode, duration); if (req.path !== '/health' && req.path !== '/api/health') { const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'debug'; log[level]('http', `${req.method} ${req.path} ${res.statusCode}`, { ms: duration, ip: req.ip, id: req.id }); } }); next(); }); // ── Tailscale authentication middleware (optional) ── const tailscaleAuthMiddleware = async (req, res, next) => { if (!tailscaleConfig.enabled || !tailscaleConfig.requireAuth) { return next(); } if (req.path === '/health' || req.path === '/api/health' || req.path.startsWith('/probe/')) { return next(); } if (req.path.startsWith('/api/tailscale/')) { return next(); } const clientIP = req.ip || req.socket?.remoteAddress || ''; const forwardedFor = req.headers['x-forwarded-for']; const realIP = req.headers['x-real-ip']; const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean); const fromTailscale = ipsToCheck.some(ip => isTailscaleIP(ip.toString().split(',')[0].trim())); if (!fromTailscale) { return res.status(403).json({ success: false, error: '[DC-120] Access denied. This dashboard requires Tailscale connection.', requiresTailscale: true, clientIP: clientIP }); } if (tailscaleConfig.allowedTailnet) { try { const status = await getTailscaleStatus(); if (status) { const clientTailscaleIP = ipsToCheck .map(ip => ip.toString().split(',')[0].trim()) .find(ip => isTailscaleIP(ip)); if (clientTailscaleIP) { const knownIPs = new Set(); for (const ip of (status.Self?.TailscaleIPs || [])) knownIPs.add(ip); for (const peer of Object.values(status.Peer || {})) { for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip); } if (!knownIPs.has(clientTailscaleIP)) { return res.status(403).json({ success: false, error: '[DC-121] Access denied. Device not in allowed tailnet.', requiresTailscale: true, clientIP }); } } } } catch (e) { log.warn('tailscale', 'Tailnet verification failed, allowing request', { error: e.message }); } } next(); }; app.use(tailscaleAuthMiddleware); // ── TOTP AUTHENTICATION ── const SESSION_COOKIE_NAME = 'dashcaddy_session'; const SESSION_DURATIONS = { '15m': 15 * 60 * 1000, '30m': 30 * 60 * 1000, '1h': 60 * 60 * 1000, '2h': 2 * 60 * 60 * 1000, '4h': 4 * 60 * 60 * 1000, '8h': 8 * 60 * 60 * 1000, '12h': 12 * 60 * 60 * 1000, '24h': 24 * 60 * 60 * 1000, 'never': null }; // IP-based session store (solves cross-domain cookie issues with .sami TLD) const ipSessions = createCache(CACHE_CONFIGS.ipSessions); function getClientIP(req) { return req.ip || req.socket?.remoteAddress || ''; } function createIPSession(req, durationKey) { const durationMs = SESSION_DURATIONS[durationKey]; if (!durationMs) { log.warn('auth', 'createIPSession: invalid duration, no session created', { durationKey }); return; } const ip = getClientIP(req); ipSessions.set(ip, { exp: Date.now() + durationMs }); } function verifyIPSession(req) { const ip = getClientIP(req); const session = ipSessions.get(ip); if (!session) return false; if (session.exp <= Date.now()) { ipSessions.delete(ip); return false; } return true; } function clearIPSession(req) { ipSessions.delete(getClientIP(req)); } function setSessionCookie(res, durationKey) { const durationMs = SESSION_DURATIONS[durationKey]; if (!durationMs) return; const maxAge = Math.floor(durationMs / 1000); const payload = { v: true, exp: Date.now() + durationMs }; const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); const key = cryptoUtils.loadOrCreateKey(); const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url'); res.setHeader('Set-Cookie', `${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax` ); } function parseCookies(cookieHeader) { const cookies = {}; if (!cookieHeader) return cookies; cookieHeader.split(';').forEach(pair => { const [name, ...rest] = pair.trim().split('='); if (name) cookies[name.trim()] = rest.join('=').trim(); }); return cookies; } function verifySessionCookie(cookieValue) { if (!cookieValue) return false; const parts = cookieValue.split('.'); if (parts.length !== 2) return false; const [payloadB64, sig] = parts; const key = cryptoUtils.loadOrCreateKey(); const expectedSig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url'); try { if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) return false; const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); return payload.v === true && payload.exp > Date.now(); } catch { return false; } } function clearSessionCookie(res) { res.setHeader('Set-Cookie', `${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax` ); } function isSessionValid(req) { if (verifyIPSession(req)) return true; const cookies = parseCookies(req.headers.cookie); if (verifySessionCookie(cookies[SESSION_COOKIE_NAME])) { const ip = getClientIP(req); if (totpConfig.sessionDuration && SESSION_DURATIONS[totpConfig.sessionDuration]) { ipSessions.set(ip, { exp: Date.now() + SESSION_DURATIONS[totpConfig.sessionDuration] }); } return true; } return false; } // ── Public routes (bypass TOTP and JWT auth) ── const PUBLIC_ROUTES = [ { path: '/health', exact: true }, { path: '/api/health', exact: true }, { path: '/probe/', prefix: true }, { path: '/api/tailscale/', prefix: true }, { path: '/api/totp/config', exact: true, method: 'GET' }, { path: '/api/totp/verify', exact: true }, { path: '/api/totp/check-session', exact: true }, { path: '/api/auth/gate/', prefix: true }, { path: '/api/auth/app-token/', prefix: true }, { path: '/api/services', exact: true, method: 'GET' }, { path: '/api/ca/info', exact: true, method: 'GET' }, { path: '/api/ca/root.crt', exact: true, method: 'GET' }, { path: '/api/ca/install-script', exact: true, method: 'GET' }, { path: '/api/health/ca', exact: true, method: 'GET' }, { path: '/api/ca/cert/', prefix: true, method: 'GET' }, { path: '/api/ca/certs', exact: true, method: 'GET' }, { path: '/api/csrf-token', exact: true, method: 'GET' }, { path: '/api/logo', exact: true, method: 'GET' }, { path: '/api/favicon', exact: true, method: 'GET' }, { path: '/api/themes', exact: true, method: 'GET' }, { path: '/api/license/status', exact: true, method: 'GET' }, { path: '/api/license/feature/', prefix: true, method: 'GET' }, ]; function isPublicRoute(req) { // Normalize /api/v1/... to /api/... so public routes work with both const p = req.path.replace(/^\/api\/v1\//, '/api/'); return PUBLIC_ROUTES.some(r => { if (r.method && req.method !== r.method) return false; return r.prefix ? p.startsWith(r.path) : p === r.path; }); } // ── TOTP auth middleware ── const totpAuthMiddleware = (req, res, next) => { if (!totpConfig.enabled || totpConfig.sessionDuration === 'never') { return next(); } if (isPublicRoute(req)) return next(); if (isSessionValid(req)) return next(); return res.status(401).json({ success: false, error: '[DC-110] Authentication required', requiresTotp: true }); }; app.use(totpAuthMiddleware); // ── JWT/API Key authentication middleware ── const jwtApiKeyAuthMiddleware = async (req, res, next) => { if (req.totpSessionValid || isSessionValid(req)) { req.auth = { type: 'session', scope: ['admin'] }; return next(); } if (isPublicRoute(req)) return next(); const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.substring(7); const jwtPayload = await authManager.verifyJWT(token); if (jwtPayload) { req.auth = { type: 'jwt', userId: jwtPayload.userId, scope: jwtPayload.scope || [] }; return next(); } } const apiKey = req.headers['x-api-key']; if (apiKey) { const keyData = await authManager.verifyAPIKey(apiKey); if (keyData) { req.auth = { type: 'apikey', keyId: keyData.keyId, name: keyData.name, scope: keyData.scopes || [] }; return next(); } } if (!totpConfig.enabled || totpConfig.sessionDuration === 'never') { req.auth = { type: 'none', scope: ['admin'] }; return next(); } return res.status(401).json({ success: false, error: '[DC-110] Authentication required - provide TOTP session, JWT token, or API key', requiresTotp: totpConfig.enabled }); }; app.use(jwtApiKeyAuthMiddleware); // ── Rate limiting (skipped in test environment) ── const isTest = process.env.NODE_ENV === 'test'; const generalLimiter = rateLimit({ ...RATE_LIMITS.GENERAL, standardHeaders: true, legacyHeaders: false, skip: (req) => isTest || req.path === '/health' || req.path === '/api/health' || req.path.startsWith('/probe/') || req.path.startsWith('/api/auth/gate/') || req.path === '/api/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs', message: { success: false, error: 'Too many requests, please try again later' } }); const strictLimiter = rateLimit({ ...RATE_LIMITS.STRICT, standardHeaders: true, legacyHeaders: false, skip: () => isTest, message: { success: false, error: 'Too many requests to this endpoint, please try again later' } }); app.use(generalLimiter); app.use('/api/dns/credentials', strictLimiter); app.use('/api/apps/deploy', strictLimiter); app.use('/api/backup/restore', strictLimiter); app.use('/api/site', strictLimiter); app.use('/api/credentials/rotate-key', strictLimiter); const totpLimiter = rateLimit({ ...RATE_LIMITS.TOTP, standardHeaders: true, legacyHeaders: false, message: { success: false, error: 'Too many TOTP attempts, please try again later' } }); app.use('/api/totp/verify', totpLimiter); app.use('/api/totp/verify-setup', totpLimiter); // ── Audit logging middleware (logs non-GET API requests) ── app.use(auditLogger.middleware()); // ── Return items that routes and ctx need ── return { strictLimiter, SESSION_DURATIONS, getClientIP, createIPSession, setSessionCookie, clearIPSession, clearSessionCookie, isSessionValid, ipSessions }; };