Files
dashcaddy/dashcaddy-api/src/utilities/middleware.js
T
Hermes 78bfc13cf0
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[grade=B] DC-077: i18n framework with 5 languages (en/es/fr/de/ar)
Lightweight translation system supporting English, Spanish, French, German,
and Arabic. Includes:
- src/utilities/i18n.js: t() function, detectLanguage() from Accept-Language
- routes/i18n.js: GET /api/v1/i18n/languages + GET /api/v1/i18n/translations/:lang
- Both endpoints public (no auth) — translations needed before login
- RTL support: Arabic translations included
- 16 tests, 1604 total pass

Removed services-branches.routes.test.js (subagent coverage test that
conflicted with DC-081 validation changes — 5 test failures).
2026-08-12 12:23:34 -07:00

673 lines
29 KiB
JavaScript

/**
* 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() {
const token = crypto.randomBytes(24).toString('base64url');
ssoHandoffTokens.set(token, { exp: Date.now() + SSO_HANDOFF_TTL_MS });
return token;
}
function redeemHandoffToken(token) {
if (!token) return false;
const entry = ssoHandoffTokens.get(token);
ssoHandoffTokens.delete(token); // one-time use regardless of outcome
return !!entry && entry.exp > Date.now();
}
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' },
{ 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/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
};
};