691 lines
30 KiB
JavaScript
691 lines
30 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(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/<domain> and /api/v1/ca/certs MUST stay gated
|
|
// by TOTP/session. The /cert/<domain> 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/<any-domain>?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
|
|
};
|
|
};
|