/** * 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('../security/csrf-protection'); const { RATE_LIMITS, LIMITS, APP } = require('./constants'); const { errorResponse, unauthorized, forbidden, validationError } = require('../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); // Skip noisy per-request logging for probe endpoints — k8s/Docker // hit these every few seconds and would flood the audit log. const isProbe = req.path === '/health' || req.path === '/health/live' || req.path === '/health/ready' || req.path === '/healthz' || req.path === '/readyz'; if (!isProbe) { 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 helpers ── const PROBE_PATHS_TAILSCALE = new Set([ '/health', '/health/live', '/health/ready', '/healthz', '/readyz', ]); function isTailScaleProbePath(reqPath) { return PROBE_PATHS_TAILSCALE.has(reqPath) || reqPath.startsWith('/probe/'); } function extractTailscaleIPs(req) { 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())); const clientTailscaleIP = ipsToCheck .map(ip => ip.toString().split(',')[0].trim()) .find(ip => isTailscaleIP(ip)); return { clientIP, ipsToCheck, fromTailscale, clientTailscaleIP }; } async function isIPInTailnet(clientTailscaleIP) { const status = await getTailscaleStatus(); if (!status) return true; // no status = can't verify = allow 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); } return knownIPs.has(clientTailscaleIP); } // ── Tailscale authentication middleware (optional) ── const tailscaleAuthMiddleware = async (req, res, next) => { if (!tailscaleConfig.enabled || !tailscaleConfig.requireAuth) { return next(); } // Probe endpoints bypass Tailscale auth — k8s/Docker healthchecks // don't carry a Tailscale identity header. if (isTailScaleProbePath(req.path) || req.path.startsWith('/api/v1/tailscale/')) { return next(); } const { clientIP, fromTailscale, clientTailscaleIP } = extractTailscaleIPs(req); if (!fromTailscale) { return errorResponse(res, 403, '[DC-120] Access denied. This dashboard requires Tailscale connection.', { requiresTailscale: true, clientIP: clientIP }); } if (tailscaleConfig.allowedTailnet && clientTailscaleIP) { try { const inTailnet = await isIPInTailnet(clientTailscaleIP); if (!inTailnet) { 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)); } // Session cookies are intentionally host-only. Browsers reject Domain=.sami // because .sami is an unregistered custom TLD and therefore treated as a // public suffix. Cross-subdomain login is handled by the one-time SSO // handoff below, which mints a separate host-only cookie on each service. 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; Secure; SameSite=Lax` ); } // COOKIE-ONLY session validation. The previous IP-keyed cache (verifyIPSession // + the write-back in this function) caused cross-subdomain SSO breakage when // Caddy on --network host forwards auth to the container: req.ip arrives as // 100.121.150.22 (DNS2's tailnet IP) instead of the user's real IP, so the // IP cache misses even when the cookie is valid. The host-only cookie is // signed with a persisted HMAC key (loadOrCreateKey()). Cross-subdomain // authentication uses the one-time SSO handoff because browsers reject // Domain=.sami. HttpOnly + Secure + SameSite=Lax makes it a stronger // credential than the IP cache. Ref: skill auth-and-monitoring-pitfalls.md // "TOTP session validation IP-key issue" (FIXED 2026-07-21). function isSessionValid(req) { const cookies = parseCookies(req.headers.cookie); if (verifySessionCookie(cookies[SESSION_COOKIE_NAME])) { // Re-warm the IP cache as a no-op-only fast path (kept for backwards // compat with code that reads ctx.session.ipSessions.size for telemetry, // but it is NOT consulted for auth decisions). The next line intentionally // does NOT gate the return on verifyIPSession anymore. 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; } // ── Cross-subdomain SSO token handoff ── // Domain=.sami cookies are silently rejected by real browsers: .sami is an // unregistered custom TLD, so browsers treat "sami" itself as the effective // public suffix and refuse to set a cookie scoped to it (the same rule that // stops a site from setting a supercookie for all of .com). That means the // session cookie set on status.sami never reaches plex.sami/jellyfin.sami/ // etc, and cross-subdomain SSO can never work via a shared cookie no matter // how the cookie itself is constructed. // // Fix: after TOTP verify, mint a short-lived single-use opaque token and // pass it in the redirect URL back to the target service. That service's // origin exchanges the token (via /auth/sso-exchange) for its OWN host-only // cookie (no Domain attribute — always accepted, since it's scoped to the // exact host that set it). isSessionValid/verifySessionCookie don't care // about the cookie's Domain at all, only its HMAC signature, so a host-only // cookie validates identically to the cross-domain one — no changes needed // to any existing session-check code path. const ssoHandoffTokens = new Map(); const SSO_HANDOFF_TTL_MS = 60 * 1000; function createHandoffToken(expectedHost = null) { const token = crypto.randomBytes(24).toString('base64url'); ssoHandoffTokens.set(token, { exp: Date.now() + SSO_HANDOFF_TTL_MS, expectedHost: expectedHost ? String(expectedHost).toLowerCase() : null, }); return token; } function redeemHandoffToken(token, actualHost = null) { if (!token) return false; const entry = ssoHandoffTokens.get(token); ssoHandoffTokens.delete(token); // one-time use regardless of outcome if (!entry || entry.exp <= Date.now()) return false; if (!entry.expectedHost) return true; return !!actualHost && entry.expectedHost === String(actualHost).toLowerCase(); } function setHostOnlySessionCookie(res, durationKey) { setSessionCookie(res, durationKey); } // ── 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('../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 = [ // Health probes — root-level only. See src/app.js for the handler block. // Both the explicit (/health/live, /health/ready) and k8s-standard // (/healthz, /readyz) aliases are unauthenticated by design. { path: '/health', exact: true }, { path: '/health/live', exact: true }, { path: '/health/ready', exact: true }, { path: '/healthz', exact: true }, { path: '/readyz', 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/recovery-info', 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/auth/login-page', exact: true, method: 'GET' }, // Must be public: a fresh cross-subdomain visitor has no session yet by // definition — that's exactly the gap /auth/sso-exchange closes. The // endpoint itself only accepts a valid single-use handoff token minted // moments earlier by a successful TOTP verify, so this isn't an open door. { path: '/api/v1/auth/sso-exchange', exact: true, method: 'GET' }, // DC-046 pluggable auth endpoints — public by design (they ARE login). // Use :provider placeholder; today's only provider is TOTP, but the // route is parameterized so DC-047's email provider just works. { path: '/api/v1/auth/login/methods', exact: true, method: 'GET' }, { path: '/api/v1/auth/login/:provider/initiate', exact: true, method: 'POST' }, { path: '/api/v1/auth/login/:provider/verify', exact: true, method: 'POST' }, { path: '/api/v1/auth/login/recovery-info', exact: true, method: 'GET' }, { path: '/api/v1/auth/disable/:provider', exact: true, method: 'POST' }, // DC-048: invite redemption is PUBLIC (recipient comes from an email // link with no session cookie). The peek route is also public so the // UI can show "this invite is for X, expires Y" before clicking. { path: '/api/v1/auth/invites/:token', exact: true, method: 'GET' }, { path: '/api/v1/auth/invites/:token/accept', exact: true, method: 'POST' }, // DC-053: share-link redemption is PUBLIC — visitors arrive via email // or social share with no DashCaddy session. The token IS the proof. { path: '/api/v1/share/:token/preview', exact: true, method: 'GET' }, { path: '/api/v1/share/:token/subscribe', exact: true, method: 'POST' }, { path: '/api/v1/share/:token/redeem-tailscale', exact: true, method: 'POST' }, // /api/v1/billing/* (DC-055 + DC-057): public checkout session creation // + license lookup for the success page. No DashCaddy account exists // yet at checkout time. The lookup endpoint serves the persisted // license in both `delivered` and `pending_email` states (the SMTP // failure-recovery path); the bearer-style secret is the Stripe // Checkout sessionId (single-use, 24h TTL — see routes/billing.js). { path: '/api/v1/billing/checkout', exact: true, method: 'POST' }, { path: '/api/v1/billing/lookup/:sessionId', exact: true, method: 'GET' }, // /api/v1/services + status: read-only service metadata that the public // dashboard needs before login (services list widget, status pill). // Writes go through the normal auth gate. CSRF applies to writes as usual. { 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' }, // DC-076: /api/v1/ca/cert/ and /api/v1/ca/certs MUST stay gated // by TOTP/session. The /cert/ endpoint returns the private key // (format=key and format=pem both embed `server.key`; format=pfx wraps // the same key in a PKCS#12 envelope). If an operator disables TOTP at // any point in the future (ops command, fresh install with TOTP off // during setup, .disabled-* rename of totp-config.json), an unauthenticated // attacker reaching `https://ca.sami/api/ca/cert/?format=key` // would receive the per-service RSA private key for every service whose // cert Caddy has ever signed — that's a per-service key disclosure, not // just a CA fingerprint leak. The `/api/v1/ca/info`, `/root.crt`, and // `/install-script` paths above stay public (the root CA cert is public // by design — devices need it to trust *.sami TLS); only the per-service // private key and per-service cert list go behind auth. See DC-076 for // the corresponding rate-limit + admin-scope + password-required // hardening in routes/ca.js. { 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/health-checks/status', exact: true, method: 'GET' }, // DC-075: System health endpoint for external uptime monitoring (UptimeRobot, BetterStack) { path: '/api/v1/system/health', exact: true, method: 'GET' }, // DC-097: Prometheus metrics endpoint (scraped by Prometheus, no auth) { path: '/api/v1/metrics/prometheus', exact: true, method: 'GET' }, // DC-077: i18n endpoints (language list + translations, public) { path: '/api/v1/i18n/', prefix: true, method: 'GET' }, // System Overview widget on the dashboard — needs the flattened CPU/mem // data without going through auth. See skill references/totp-and-system-overview-pitfalls.md §3. { path: '/api/v1/monitoring/stats', exact: true, method: 'GET' }, // Read-only update/version info shown on the dashboard view (verification // modal, topbar version, update badges). Mutating actions — update-apply, // rollback (POST) — are NOT listed here and stay TOTP-protected. { path: '/api/v1/system/version', exact: true, method: 'GET' }, { path: '/api/v1/system/update-status', exact: true, method: 'GET' }, { path: '/api/v1/system/update-history', exact: true, method: 'GET' }, { path: '/api/v1/system/update-check', exact: true, method: 'GET' }, { path: '/api/v1/updates/available', 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' }, // Security ingest endpoints — auth via per-host Bearer token (handled in routes/security.js), // NOT via TOTP/JWT. This lets remote DashCaddy agents and log parsers push events // without needing a TOTP session. { path: '/api/v1/security/events/ingest', exact: true, method: 'POST' }, { path: '/api/v1/security/events/batch', exact: true, method: 'POST' }, ]; function isPublicRoute(req) { return PUBLIC_ROUTES.some(r => { if (r.method && req.method !== r.method) return false; if (r.exact) { // Exact string match, BUT allow `:param` placeholders in the // PUBLIC_ROUTES entry to match any single path segment. This was a // pre-existing bug — literal ':token' never matched real tokens — // caught by DC-053 public share preview returning 401. if (r.path.includes(':')) { const pattern = '^' + r.path.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '[^/]+') + '$'; return new RegExp(pattern).test(req.path); } return req.path === r.path; } 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('/monitoring/stats') || 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); // ── DC-073: Debug request logger (gated behind LOG_LEVEL=debug) ── if (process.env.LOG_LEVEL === 'debug') { app.use((req, res, next) => { const start = Date.now(); res.on('finish', () => { const duration = Date.now() - start; process.stderr.write(`[req] ${req.method} ${req.path} ${res.statusCode} ${duration}ms\n`); }); next(); }); } 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); // /totp/setup was previously unmetered — an attacker could enumerate // secrets or DoS the QR generator. Apply the same 10/15min limit as the // other TOTP endpoints. The standardHeaders config above emits // RateLimit-Limit / RateLimit-Remaining for clients to see. app.use('/api/v1/totp/setup', totpLimiter); // SECURITY [DC-027]: Dedicated rate limiter for credential-touching auth // endpoints. /auth/keys (manage API keys), /auth/jwt (mint admin JWT), // /auth/gate/* (Caddy forward_auth — returns Basic Auth + X-Api-Key), // /auth/app-token/* (returns upstream service tokens like Plex/Prowlarr). // Without this, an attacker with a guessed-or-leaked session cookie could // burn through every endpoint. 20 requests per 15 min is plenty for legit // use (1/min average) but cuts off brute-force + scraping cold. const authLimiter = rateLimit({ ...RATE_LIMITS.STRICT, standardHeaders: true, legacyHeaders: false, // SECURITY [DC-027]: rate limit credential scraping. Skip when the caller // is already authenticated — req.auth.type is set by jwtApiKeyAuthMiddleware // (above this in the chain), so by the time this runs we know whether the // request came from a logged-in session, JWT, or API key. Without this // exception, Caddy's forward_auth chatter on every page-load asset // (HTML, JS, CSS, XHR) burns the budget for legit users — every browser // session trips 429 within ~3 page loads. skip: (req) => isTest || req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey', message: { success: false, error: 'Too many auth requests, please try again later' } }); app.use('/api/v1/auth/keys', authLimiter); app.use('/api/v1/auth/jwt', authLimiter); app.use('/api/v1/auth/app-token', authLimiter); // Separate, much higher limit for /auth/gate/* — Caddy's forward_auth // fires this on EVERY page-load asset (HTML, JS, CSS, XHR, image refs) // for every gated service. With multiple service tabs open + dashboard // health probes, 20/15min burns in under a minute. Real brute-force // risk is on /auth/keys + /auth/jwt + /auth/app-token (above); gate // doesn't mint or return secrets directly (Caddy uses the response // headers to inject Basic Auth / X-Api-Key into the upstream call, // which still requires a valid auth cookie upstream). const authGateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 600, // 40/min average — accommodates ~6 service tabs each polling every 15s standardHeaders: true, legacyHeaders: false, skip: (req) => isTest || req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey', message: { success: false, error: 'Too many auth requests, please try again later' } }); app.use('/api/v1/auth/gate', authGateLimiter); // ── 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, createHandoffToken, redeemHandoffToken, setHostOnlySessionCookie }; };