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