/** * 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 { createCSRFMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('./csrf-protection'); const { RATE_LIMITS, LIMITS, APP } = require('./constants'); const { errorResponse, unauthorized, forbidden, validationError } = require('./src/utils/responses'); 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 validationError(res, '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 (cookie domain set to TLD for cross-subdomain SSO) ── const { csrfCookieMiddleware, renewCSRFToken } = createCSRFMiddleware({ cookieDomain: siteConfig.tld || undefined }); 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/v1/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/v1/health' || req.path.startsWith('/probe/')) { return next(); } if (req.path.startsWith('/api/v1/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 errorResponse(res, 403, '[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 errorResponse(res, 403, '[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'); const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : ''; res.setHeader('Set-Cookie', `${SESSION_COOKIE_NAME}=${payloadB64}.${sig}${domainAttr}; 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) { const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : ''; res.setHeader('Set-Cookie', `${SESSION_COOKIE_NAME}=; Max-Age=0${domainAttr}; 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) ── // Routes here are accessible without authentication. By default the // monitoring/health-check endpoints are public so the dashboard can // render widgets before the user logs in. Set MONITORING_PUBLIC=false // (env var) or `monitoring: { public: false }` (config.json) to require // auth for these — useful for internet-exposed deployments where // CPU/memory/disk data is sensitive. const MONITORING_PUBLIC = (() => { if (process.env.MONITORING_PUBLIC === 'false') return false; if (process.env.MONITORING_PUBLIC === 'true') return true; // Default: check config.json if loaded try { const cfg = require('./src/config/site').siteConfig; if (cfg && cfg.monitoring && typeof cfg.monitoring.public === 'boolean') { return cfg.monitoring.public; } } catch { /* config not loaded yet, use default */ } return true; // default: public (current behavior, dashboard needs it) })(); const PUBLIC_ROUTES = [ { path: '/health', exact: true }, { path: '/health/live', exact: true }, { path: '/health/ready', exact: true }, { path: '/api/v1/health', exact: true }, { path: '/api/v1/health/live', exact: true }, { path: '/api/v1/health/ready', exact: true }, { path: '/probe/', prefix: true }, { path: '/api/v1/tailscale/', prefix: true }, { path: '/api/v1/totp/config', exact: true, method: 'GET' }, { path: '/api/v1/totp/verify', exact: true }, { path: '/api/v1/totp/setup', exact: true, method: 'POST' }, { path: '/api/v1/totp/verify-setup', exact: true, method: 'POST' }, { path: '/api/v1/totp/check-session', exact: true }, { path: '/api/v1/auth/gate/', prefix: true }, { path: '/api/v1/auth/app-token/', prefix: true }, { path: '/api/v1/services', exact: true, method: 'GET' }, { path: '/api/v1/ca/info', exact: true, method: 'GET' }, { path: '/api/v1/ca/root.crt', exact: true, method: 'GET' }, { path: '/api/v1/ca/install-script', exact: true, method: 'GET' }, { path: '/api/v1/health/ca', exact: true, method: 'GET' }, { path: '/api/v1/ca/cert/', prefix: true, method: 'GET' }, { path: '/api/v1/ca/certs', exact: true, method: 'GET' }, { path: '/api/v1/csrf-token', exact: true, method: 'GET' }, { path: '/api/v1/logo', exact: true, method: 'GET' }, { path: '/api/v1/favicon', exact: true, method: 'GET' }, { path: '/api/v1/themes', exact: true, method: 'GET' }, { path: '/api/v1/license/status', exact: true, method: 'GET' }, { path: '/api/v1/license/feature/', prefix: true, method: 'GET' }, { path: '/api/v1/config', exact: true, method: 'GET' }, { path: '/api/v1/services/status', exact: true, method: 'GET' }, { path: '/api/v1/system/update-notify', exact: true, method: 'POST' }, // Monitoring endpoints — only public if MONITORING_PUBLIC is true ...(MONITORING_PUBLIC ? [ { path: '/api/v1/monitoring/stats', exact: true, method: 'GET' }, { path: '/api/v1/health-checks/status', exact: true, method: 'GET' }, ] : []), { path: '/api/v1/version', exact: true, method: 'GET' }, ]; function isPublicRoute(req) { return PUBLIC_ROUTES.some(r => { if (r.method && req.method !== r.method) return false; return r.prefix ? req.path.startsWith(r.path) : req.path === r.path; }); } // ── TOTP auth middleware ── const totpAuthMiddleware = (req, res, next) => { // If TOTP is not enabled at all, skip auth entirely — this is the initial-setup state if (!totpConfig.enabled) { req.auth = { type: 'none', scope: ['admin'] }; return next(); } // TOTP is enabled — require a valid session, JWT, or API key if (isPublicRoute(req)) return next(); if (isSessionValid(req)) return next(); return errorResponse(res, 401, '[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(); } } // No valid auth — reject return errorResponse(res, 401, '[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/v1/health' || req.path.startsWith('/probe/') || req.path.startsWith('/api/v1/auth/gate/') || req.path === '/api/v1/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs' || req.path === '/api/v1/license/status' || req.path.startsWith('/api/v1/license/feature/') || req.path === '/api/v1/services' || req.path === '/api/v1/config', 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/v1/dns/credentials', strictLimiter); app.use('/api/v1/apps/deploy', strictLimiter); app.use('/api/v1/backup/restore', strictLimiter); app.use('/api/v1/site', strictLimiter); app.use('/api/v1/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/v1/totp/verify', totpLimiter); app.use('/api/v1/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, renewCSRFToken }; };