diff --git a/README.md b/README.md index dae7fc2..a2566f7 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,21 @@ Each price source (`stellar_dex`, `coingecko`, `coinmarketcap`) gets one circuit | `API_KEY_RATELIMIT_FREE_MAX` | Requests per window for `free`-tier keys | 100 | No | | `API_KEY_RATELIMIT_PRO_MAX` | Requests per window for `pro`-tier keys | 1000 | No | | `API_KEY_RATELIMIT_ADMIN_MAX` | Requests per window for `admin`-tier keys | 10000 | No | +| `TRUST_PROXY` | Express trust proxy setting (`true`, `false`, `loopback`, IP/subnet, or hops) | `loopback` in production, `false` otherwise | No | +| `ENFORCE_HTTPS` | Enforce HTTPS redirects (308) and reject state-changing plain HTTP | `true` in production, `false` otherwise | No | +| `HTTPS_HOST` | Fixed target public host for HTTPS redirection | empty | No | +| `ALLOWED_HOSTS` | Comma-separated allowlist of valid redirection hosts | empty | No | +| `HSTS_MAX_AGE_SECONDS` | HSTS max-age duration in seconds | 31536000 | No | +| `HSTS_INCLUDE_SUBDOMAINS` | Include subdomains in HSTS header | true | No | +| `HSTS_PRELOAD` | Opt-in to HSTS preloading | false | No | +| `SESSION_ABSOLUTE_LIFETIME_SECONDS` | Absolute maximum session lifetime in seconds (activity cannot extend) | 86400 (24h) | No | +| `SESSION_IDLE_TIMEOUT_SECONDS` | Inactivity timeout in seconds that activity extends | 1800 (30m) | No | +| `SESSION_ACTIVITY_THROTTLE_SECONDS` | Minimum interval in seconds between session store activity writes | 60 | No | +| `SESSION_CLOCK_SKEW_SECONDS` | Clock-skew leeway in seconds for expiry comparison | 60 | No | +| `CSRF_SECRET` | Secret key for signing per-session CSRF tokens (HMAC-SHA256) | auto-generated | No | +| `METRICS_ENABLED` | Enable Prometheus text exposition endpoint at `/metrics` | true | No | +| `METRICS_AUTH_TOKEN` | Bearer token / password for authenticating `/metrics` scrapes | empty | No | +| `METRICS_ALLOWED_IPS` | Comma-separated list of IP addresses allowed to scrape `/metrics` | empty | No | --- diff --git a/src/auth/session.js b/src/auth/session.js new file mode 100644 index 0000000..fe74742 --- /dev/null +++ b/src/auth/session.js @@ -0,0 +1,382 @@ +'use strict'; + +/** + * Session management service with server-side expiry enforcement (Issue #469). + * + * Enforces dual-window expiry: + * 1. Absolute lifetime: fixed maximum session duration that activity can NEVER extend. + * 2. Idle timeout: inactivity window that extends on activity within the absolute lifetime. + * + * Guarantees: + * - All date arithmetic is performed in UTC. + * - Clock skew leeway is respected. + * - Store writes on activity updates are throttled to prevent write storms. + * - Expired or missing-expiry sessions/tokens return 401 UNAUTHORIZED. + * - Native store TTL + periodic unref'd cleanup timer. + * - Server-side invalidation on logout. + * - Legacy sessions without explicit expiry fields are supported by deriving from created_at. + * - Cookie options helper strictly mirrors remaining server-side session lifetime. + */ + +const crypto = require('crypto'); +const cache = require('../services/cache'); +const logger = require('../logger'); +const AppError = require('../errors/AppError'); + +function getEnvNumber(key, defaultValue) { + const val = process.env[key]; + if (val !== undefined && val !== '') { + const num = Number(val); + if (Number.isSafeInteger(num) && num > 0) return num; + } + return defaultValue; +} + +// Configuration with documented defaults +const ABSOLUTE_LIFETIME_SECONDS = getEnvNumber('SESSION_ABSOLUTE_LIFETIME_SECONDS', 24 * 60 * 60); // 24 hours +const IDLE_TIMEOUT_SECONDS = getEnvNumber('SESSION_IDLE_TIMEOUT_SECONDS', 30 * 60); // 30 minutes +const ACTIVITY_THROTTLE_SECONDS = getEnvNumber('SESSION_ACTIVITY_THROTTLE_SECONDS', 60); // 1 minute +const CLOCK_SKEW_SECONDS = getEnvNumber('SESSION_CLOCK_SKEW_SECONDS', 60); // 60 seconds + +const SESSIONS_ACTIVE_INDEX = 'sessions:active'; +let cleanupTimer = null; + +function sessionKey(id) { + return `session:${id}`; +} + +function generateSessionId() { + return `sess_${crypto.randomUUID().replace(/-/g, '')}`; +} + +function sanitizeSession(record) { + if (!record) return null; + return { ...record }; +} + +/** + * Derives expiry boundaries in UTC milliseconds. + * If record lacks absolute_expires_at or idle_expires_at (legacy session), + * derives them safely from created_at without requiring a database migration. + */ +function getSessionTimestamps(record) { + const createdAtMs = Date.parse(record.created_at) || Date.now(); + const lastActivityMs = Date.parse(record.last_activity_at || record.last_validated_at) || createdAtMs; + + const absoluteExpiresAtMs = record.absolute_expires_at + ? Date.parse(record.absolute_expires_at) + : createdAtMs + ABSOLUTE_LIFETIME_SECONDS * 1000; + + const idleExpiresAtMs = record.idle_expires_at + ? Date.parse(record.idle_expires_at) + : lastActivityMs + IDLE_TIMEOUT_SECONDS * 1000; + + return { + createdAtMs, + lastActivityMs, + absoluteExpiresAtMs, + idleExpiresAtMs, + }; +} + +/** + * Check if a session record has expired against either absolute or idle window. + */ +function isSessionExpired(record, nowMs = Date.now()) { + if (!record || record.active === false) return true; + + const { absoluteExpiresAtMs, idleExpiresAtMs } = getSessionTimestamps(record); + const leewayMs = CLOCK_SKEW_SECONDS * 1000; + + // 1. Check absolute lifetime: activity can NEVER extend this window + if (nowMs > absoluteExpiresAtMs + leewayMs) { + return true; + } + + // 2. Check idle timeout: exceeded inactivity window + if (nowMs > idleExpiresAtMs + leewayMs) { + return true; + } + + return false; +} + +/** + * Create a new server-side session. + */ +async function createSession(userId, metadata = {}) { + if (!userId) { + throw new Error('createSession: userId is required'); + } + + const now = new Date(); + const nowMs = now.getTime(); + const absoluteExpiresAtMs = nowMs + ABSOLUTE_LIFETIME_SECONDS * 1000; + const idleExpiresAtMs = nowMs + IDLE_TIMEOUT_SECONDS * 1000; + const effectiveExpiryMs = Math.min(absoluteExpiresAtMs, idleExpiresAtMs); + + const record = { + id: generateSessionId(), + user_id: userId, + active: true, + created_at: now.toISOString(), + last_activity_at: now.toISOString(), + absolute_expires_at: new Date(absoluteExpiresAtMs).toISOString(), + idle_expires_at: new Date(idleExpiresAtMs).toISOString(), + ip_address: metadata.ipAddress || null, + user_agent: metadata.userAgent || null, + }; + + const ttlSeconds = Math.max(1, Math.ceil((absoluteExpiresAtMs - nowMs) / 1000)); + await cache.set(sessionKey(record.id), record, ttlSeconds); + + try { + const redis = cache.getClient(); + if (redis && typeof redis.zadd === 'function') { + await redis.zadd(SESSIONS_ACTIVE_INDEX, effectiveExpiryMs, record.id); + } + } catch (err) { + logger.warn('Failed to add session to active index', { error: err.message }); + } + + return sanitizeSession(record); +} + +/** + * Validate session and update last-activity with throttling. + * + * If session is expired or invalid: returns null. + * If activity write is throttled: returns session without Redis write. + * If throttle window has elapsed: updates last_activity_at and extends idle timeout + * (up to the fixed absolute lifetime). + */ +async function validateSession(sessionId, { touch = true, now = new Date() } = {}) { + if (!sessionId) return null; + + const record = await cache.get(sessionKey(sessionId)); + if (!record) return null; + + const nowMs = now.getTime(); + if (isSessionExpired(record, nowMs)) { + // Lazily clean up expired session + await invalidateSession(sessionId); + return null; + } + + if (!touch) { + return sanitizeSession(record); + } + + const { lastActivityMs, absoluteExpiresAtMs } = getSessionTimestamps(record); + const throttleMs = ACTIVITY_THROTTLE_SECONDS * 1000; + + // Throttling: If last write was within throttle window, DO NOT write to store + if (nowMs - lastActivityMs < throttleMs) { + return sanitizeSession(record); + } + + // Extend idle expiry, but NEVER exceed the absolute lifetime + const newIdleExpiresAtMs = Math.min(nowMs + IDLE_TIMEOUT_SECONDS * 1000, absoluteExpiresAtMs); + const remainingTtlSeconds = Math.max(1, Math.ceil((newIdleExpiresAtMs - nowMs) / 1000)); + + const updatedRecord = { + ...record, + last_activity_at: now.toISOString(), + idle_expires_at: new Date(newIdleExpiresAtMs).toISOString(), + // Preserve or derive absolute_expires_at for legacy sessions + absolute_expires_at: record.absolute_expires_at || new Date(absoluteExpiresAtMs).toISOString(), + }; + + await cache.set(sessionKey(sessionId), updatedRecord, remainingTtlSeconds); + + try { + const redis = cache.getClient(); + if (redis && typeof redis.zadd === 'function') { + await redis.zadd(SESSIONS_ACTIVE_INDEX, newIdleExpiresAtMs, sessionId); + } + } catch (err) { + logger.warn('Failed to update session active index score', { error: err.message }); + } + + return sanitizeSession(updatedRecord); +} + +/** + * Invalidate a session server-side (logout / revocation). + */ +async function invalidateSession(sessionId) { + if (!sessionId) return false; + + try { + await cache.del(sessionKey(sessionId)); + const redis = cache.getClient(); + if (redis && typeof redis.zrem === 'function') { + await redis.zrem(SESSIONS_ACTIVE_INDEX, sessionId); + } + return true; + } catch (err) { + logger.error('Failed to invalidate session', { sessionId, error: err.message }); + return false; + } +} + +/** + * Scheduled cleanup of expired sessions from active index. + */ +async function cleanupExpiredSessions({ limit = 500, nowMs = Date.now() } = {}) { + try { + const redis = cache.getClient(); + if (!redis || typeof redis.zrangebyscore !== 'function') { + return { cleaned: 0, checked: 0 }; + } + + const expiredIds = await redis.zrangebyscore( + SESSIONS_ACTIVE_INDEX, + 0, + nowMs, + 'LIMIT', + 0, + limit + ); + + if (!expiredIds || expiredIds.length === 0) { + return { cleaned: 0, checked: 0 }; + } + + const pipeline = redis.multi ? redis.multi() : null; + if (pipeline) { + expiredIds.forEach((id) => pipeline.zrem(SESSIONS_ACTIVE_INDEX, id)); + await pipeline.exec(); + } else { + await Promise.all(expiredIds.map((id) => redis.zrem(SESSIONS_ACTIVE_INDEX, id))); + } + + await Promise.all(expiredIds.map((id) => cache.del(sessionKey(id)))); + return { cleaned: expiredIds.length, checked: expiredIds.length }; + } catch (err) { + logger.error('cleanupExpiredSessions error', { error: err.message }); + return { cleaned: 0, checked: 0 }; + } +} + +/** + * Start background periodic cleanup with an unref'd timer (no listener leaks, no hanging process). + */ +function startCleanupTimer(intervalMs = 15 * 60 * 1000) { + if (cleanupTimer) return; + cleanupTimer = setInterval(() => { + cleanupExpiredSessions().catch((err) => { + logger.warn('Periodic session cleanup encountered error', { error: err.message }); + }); + }, intervalMs); + + if (typeof cleanupTimer.unref === 'function') { + cleanupTimer.unref(); + } +} + +function stopCleanupTimer() { + if (cleanupTimer) { + clearInterval(cleanupTimer); + cleanupTimer = null; + } +} + +/** + * Helper to compute secure cookie options aligned with server-side session expiry (#496). + */ +function getSessionCookieOptions(session, options = {}) { + const isSecure = options.isSecure !== undefined + ? options.isSecure + : process.env.NODE_ENV === 'production'; + + const nowMs = Date.now(); + let maxAgeMs = IDLE_TIMEOUT_SECONDS * 1000; + + if (session) { + const { absoluteExpiresAtMs, idleExpiresAtMs } = getSessionTimestamps(session); + const effectiveExpiryMs = Math.min(absoluteExpiresAtMs, idleExpiresAtMs); + maxAgeMs = Math.max(0, effectiveExpiryMs - nowMs); + } + + return { + httpOnly: true, + secure: isSecure, + sameSite: 'lax', + path: '/', + maxAge: maxAgeMs, + }; +} + +/** + * Express middleware for authenticating requests via session. + */ +function sessionAuth(options = {}) { + const { required = true } = options; + + return async (req, res, next) => { + const rawCookie = req.headers.cookie || ''; + let sessionId = null; + + // Extract session from cookie: session_id=... + const cookieMatch = rawCookie.match(/(?:^|;\s*)session_id=([^;]+)/); + if (cookieMatch) { + sessionId = decodeURIComponent(cookieMatch[1]); + } else if (req.get('x-session-id')) { + sessionId = req.get('x-session-id'); + } + + if (!sessionId) { + if (required) { + return next(new AppError('UNAUTHORIZED', 'Invalid credentials', 401)); + } + return next(); + } + + try { + // Validate session; pass touch: false so a request that subsequently fails + // will NOT have extended session activity + const session = await validateSession(sessionId, { touch: false }); + if (!session) { + return next(new AppError('UNAUTHORIZED', 'Invalid credentials', 401)); + } + + req.session = session; + req.user = { id: session.user_id }; + + // Touch session only when response finishes successfully without auth failure + res.on('finish', () => { + if (res.statusCode < 400) { + validateSession(sessionId, { touch: true }).catch((err) => { + logger.warn('Failed to touch session on request finish', { + sessionId, + error: err.message, + }); + }); + } + }); + + return next(); + } catch (err) { + logger.error('Session authentication error', { error: err.message }); + return next(new AppError('UNAUTHORIZED', 'Invalid credentials', 401)); + } + }; +} + +module.exports = { + createSession, + validateSession, + invalidateSession, + cleanupExpiredSessions, + startCleanupTimer, + stopCleanupTimer, + getSessionCookieOptions, + sessionAuth, + isSessionExpired, + getSessionTimestamps, + ABSOLUTE_LIFETIME_SECONDS, + IDLE_TIMEOUT_SECONDS, + ACTIVITY_THROTTLE_SECONDS, + CLOCK_SKEW_SECONDS, +}; diff --git a/src/config.js b/src/config.js index 48f6c40..607c671 100644 --- a/src/config.js +++ b/src/config.js @@ -67,6 +67,18 @@ const databaseDevDefault = ? 'postgres://localhost/smartdrop_test' : 'postgres://localhost/smartdrop'; +function parseTrustProxy(value, nodeEnv) { + if (value === undefined || value === '') { + return nodeEnv === 'production' ? 'loopback' : false; + } + const trimmed = String(value).trim().toLowerCase(); + if (trimmed === 'true') return true; + if (trimmed === 'false') return false; + const numVal = Number(trimmed); + if (!Number.isNaN(numVal)) return numVal; + return value; +} + const rawEnv = { ...process.env, NODE_ENV: process.env.NODE_ENV || 'development', @@ -269,6 +281,21 @@ const config = module.exports = { sentryDsn: env.SENTRY_DSN, slowRequestThresholdMs: env.SLOW_REQUEST_THRESHOLD_MS, routeTimeoutMs: env.ROUTE_TIMEOUT_MS, + trustProxy: parseTrustProxy(process.env.TRUST_PROXY, env.NODE_ENV), + https: { + enforce: process.env.ENFORCE_HTTPS !== undefined && process.env.ENFORCE_HTTPS !== '' + ? process.env.ENFORCE_HTTPS === 'true' + : env.NODE_ENV === 'production', + host: process.env.HTTPS_HOST || process.env.PUBLIC_HOSTNAME || '', + allowedHosts: (process.env.ALLOWED_HOSTS || '') + .split(',') + .map((h) => h.trim()) + .filter(Boolean), + hstsMaxAge: parseInt(process.env.HSTS_MAX_AGE_SECONDS, 10) || 31536000, + hstsIncludeSubDomains: process.env.HSTS_INCLUDE_SUBDOMAINS !== 'false', + // Preload is strictly opt-in because removal from browser preload lists takes months. + hstsPreload: process.env.HSTS_PRELOAD === 'true', + }, webhookSecretEncryptionKey: env.WEBHOOK_SECRET_ENCRYPTION_KEY, databaseEncryptionKey: env.DB_ENCRYPTION_KEY, dbEncryptionKey: env.DB_ENCRYPTION_KEY, @@ -451,6 +478,20 @@ function reload() { sentryDsn: reloaded.SENTRY_DSN, slowRequestThresholdMs: reloaded.SLOW_REQUEST_THRESHOLD_MS, routeTimeoutMs: reloaded.ROUTE_TIMEOUT_MS, + trustProxy: parseTrustProxy(process.env.TRUST_PROXY, reloaded.NODE_ENV), + https: { + enforce: process.env.ENFORCE_HTTPS !== undefined && process.env.ENFORCE_HTTPS !== '' + ? process.env.ENFORCE_HTTPS === 'true' + : reloaded.NODE_ENV === 'production', + host: process.env.HTTPS_HOST || process.env.PUBLIC_HOSTNAME || '', + allowedHosts: (process.env.ALLOWED_HOSTS || '') + .split(',') + .map((h) => h.trim()) + .filter(Boolean), + hstsMaxAge: parseInt(process.env.HSTS_MAX_AGE_SECONDS, 10) || 31536000, + hstsIncludeSubDomains: process.env.HSTS_INCLUDE_SUBDOMAINS !== 'false', + hstsPreload: process.env.HSTS_PRELOAD === 'true', + }, webhookSecretEncryptionKey: reloaded.WEBHOOK_SECRET_ENCRYPTION_KEY, databaseEncryptionKey: reloaded.DB_ENCRYPTION_KEY, dbEncryptionKey: reloaded.DB_ENCRYPTION_KEY, diff --git a/src/index.js b/src/index.js index 8d4f7df..b2cbc0c 100644 --- a/src/index.js +++ b/src/index.js @@ -42,6 +42,10 @@ const { attachApiKey, auditApiKeyUsage, } = require("./middleware/auth"); +const { + csrfProtectionMiddleware, + csrfTokenHandler, +} = require("./middleware/csrf"); const { errorHandler, notFoundHandler } = require("./middleware/errorHandler"); const { jsonReplacer } = require("./utils/serialization"); const { checkDatabase } = require("./services/dbHealth"); @@ -57,6 +61,7 @@ const { router: metricsRouter, requestMetricsMiddleware, } = require("./routes/metrics"); +const { httpsEnforcementMiddleware } = require("./middleware/httpsEnforcement"); const healthRouter = require("./health"); const priceWebSocket = require("./ws/priceWebSocket"); @@ -111,6 +116,8 @@ app.use(headerSecurityMiddleware); app.use(requestIdMiddleware); app.use(requestLoggerMiddleware); app.use(requestMetricsMiddleware); +// #468 — Enforce HTTPS early before payload parsing or route processing. +app.use(httpsEnforcementMiddleware); app.use(compression()); app.use( helmet({ @@ -121,6 +128,9 @@ app.use( referrerPolicy: { policy: "strict-origin-when-cross-origin" }, xContentTypeOptions: true, xDownloadOptions: true, + // #468: HSTS is managed exclusively in httpsEnforcementMiddleware so it is + // sent ONLY on secure responses and matches configured max-age/subdomains/preload. + hsts: false, }) ); app.use(buildCorsMiddleware(config.corsAllowedOrigins)); @@ -306,9 +316,14 @@ app.use(globalRateLimit); app.use(requestValidationMiddleware); app.use("/static", createStaticAssetsMiddleware(config.staticAssets)); +// #465 — Global CSRF protection for state-changing requests and CSRF token endpoint +app.get("/csrf-token", csrfTokenHandler); +app.use(csrfProtectionMiddleware); + // ==================== API ROUTING & VERSIONING (#460) ==================== // Build the core v1 API router. const apiV1Router = express.Router(); +apiV1Router.get("/csrf-token", csrfTokenHandler); apiV1Router.use(attachApiKey()); apiV1Router.use(auditApiKeyUsage()); apiV1Router.use(apiKeyLimit); diff --git a/src/metrics/index.js b/src/metrics/index.js new file mode 100644 index 0000000..8f24a0b --- /dev/null +++ b/src/metrics/index.js @@ -0,0 +1,342 @@ +'use strict'; + +/** + * SmartDrop Prometheus Metrics Exporter (Issue #467). + * + * Implements: + * - Prometheus text exposition format (version 0.0.4) + * - HTTP metrics (requests_total, duration histogram, in_flight gauge) + * - Cardinality control: labeled by route template or 'unmatched', never raw URLs + * - Process metrics (CPU, memory, uptime, event loop lag) + * - Subsystem telemetry (cache hits/misses, Redis, circuit breakers, webhooks, indexer) + * - Endpoint protection (bearer token / basic credentials via constant-time comparison) + * - Avoids recursion: /metrics itself is excluded from HTTP traffic counts + */ + +const crypto = require('crypto'); +const { Registry } = require('./registry'); +const cache = require('../services/cache'); +const AppError = require('../errors/AppError'); +const logger = require('../logger'); + +const registry = new Registry(); + +// ── HTTP Metrics ───────────────────────────────────────────────────────────── +const httpRequestsTotal = registry.counter( + 'http_requests_total', + 'Total HTTP requests processed' +); + +const httpRequestDuration = registry.histogram( + 'http_request_duration_seconds', + 'HTTP request latency in seconds', + { buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] } +); + +const httpRequestsInFlight = registry.gauge( + 'http_requests_in_flight', + 'Current number of HTTP requests being processed' +); + +// ── Process Metrics ────────────────────────────────────────────────────────── +const processCpuUser = registry.counter( + 'process_cpu_user_seconds_total', + 'Total user CPU time spent in seconds' +); +const processCpuSystem = registry.counter( + 'process_cpu_system_seconds_total', + 'Total system CPU time spent in seconds' +); +const processResidentMemory = registry.gauge( + 'process_resident_memory_bytes', + 'Resident set size (RSS) memory in bytes' +); +const processHeapUsed = registry.gauge( + 'process_heap_bytes', + 'Process heap memory used in bytes' +); +const processHeapTotal = registry.gauge( + 'process_heap_total_bytes', + 'Total allocated process heap in bytes' +); +const processStartTime = registry.gauge( + 'process_start_time_seconds', + 'Start time of the process since unix epoch in seconds' +); +const processUptime = registry.gauge( + 'process_uptime_seconds', + 'Number of seconds the process has been running' +); +const eventLoopLagGauge = registry.gauge( + 'nodejs_eventloop_lag_seconds', + 'Event loop lag in seconds' +); + +// Set start time once +processStartTime.set({}, Math.floor(Date.now() / 1000 - process.uptime())); + +// ── Subsystem Metrics ──────────────────────────────────────────────────────── +const priceCacheHits = registry.counter( + 'price_cache_hits_total', + 'Total price cache hits' +); +const priceCacheMisses = registry.counter( + 'price_cache_misses_total', + 'Total price cache misses' +); +const webhookDeliveriesTotal = registry.counter( + 'webhook_deliveries_total', + 'Total webhook deliveries attempted' +); +const webhookDeliveryErrors = registry.counter( + 'webhook_delivery_errors_total', + 'Total failed webhook deliveries' +); +const redisConnectedGauge = registry.gauge( + 'redis_connected', + 'Whether Redis client is connected and ready (1 = connected, 0 = disconnected)' +); +const redisQueueDepthGauge = registry.gauge( + 'redis_command_queue_depth', + 'Number of pending commands in Redis client command queue' +); +const circuitBreakerStateGauge = registry.gauge( + 'circuit_breaker_state', + 'State of circuit breakers (0 = CLOSED, 1 = HALF_OPEN, 2 = OPEN)' +); + +// Event loop lag measurement +let lastLagCheck = Date.now(); +let eventLoopLagSeconds = 0; +const lagInterval = setInterval(() => { + const now = Date.now(); + const delta = now - lastLagCheck - 1000; + eventLoopLagSeconds = Math.max(0, delta) / 1000; + lastLagCheck = now; +}, 1000); +if (typeof lagInterval.unref === 'function') { + lagInterval.unref(); +} + +/** + * Safely resolves the matched route pattern without exposing high cardinality + * path parameters or leaking sensitive data. + */ +function resolveRoutePattern(req, res) { + // If request route is /metrics, return /metrics + if (req.path === '/metrics') return '/metrics'; + + // Express attaches route information when a router matches + if (req.route && req.route.path) { + const base = req.baseUrl || ''; + let routePath = req.route.path; + if (routePath instanceof RegExp) { + routePath = routePath.toString(); + } + return `${base}${routePath}`; + } + + // If status is 404 or unmatched + if (res.statusCode === 404) { + return 'unmatched'; + } + + // Handle standard known versioned paths + const cleanPath = (req.path || '').split('?')[0]; + if (cleanPath.startsWith('/health')) return '/health'; + if (cleanPath.startsWith('/api-docs')) return '/api-docs'; + if (cleanPath === '/csrf-token') return '/csrf-token'; + + return 'unmatched'; +} + +/** + * Express middleware to observe HTTP traffic. + * Placed early to count requests rejected by earlier middleware. + */ +function requestMetricsMiddleware(req, res, next) { + // Do NOT count /metrics itself to avoid recursive distortion of operational metrics + if (req.path === '/metrics') { + return next(); + } + + httpRequestsInFlight.inc(); + const startTime = process.hrtime.bigint(); + + res.on('finish', () => { + httpRequestsInFlight.dec(); + const durationSec = Number(process.hrtime.bigint() - startTime) / 1e9; + const method = req.method ? req.method.toUpperCase() : 'UNKNOWN'; + const status = String(res.statusCode || 200); + const route = resolveRoutePattern(req, res); + + const labels = { method, route, status }; + httpRequestsTotal.inc(labels); + httpRequestDuration.observe(labels, durationSec); + }); + + next(); +} + +/** + * Updates process telemetry right before serializing output. + */ +function updateProcessMetrics() { + const cpu = process.cpuUsage(); + processCpuUser.set({}, cpu.user / 1e6); + processCpuSystem.set({}, cpu.system / 1e6); + + const mem = process.memoryUsage(); + processResidentMemory.set({}, mem.rss); + processHeapUsed.set({}, mem.heapUsed); + processHeapTotal.set({}, mem.heapTotal); + + processUptime.set({}, Math.floor(process.uptime())); + eventLoopLagGauge.set({}, eventLoopLagSeconds); +} + +/** + * Updates subsystem telemetry right before serializing output. + */ +async function updateSubsystemMetrics() { + // Redis state + try { + const connected = cache.isConnected && cache.isConnected(); + redisConnectedGauge.set({}, connected ? 1 : 0); + const queueDepth = cache.getCommandQueueLength ? cache.getCommandQueueLength() : 0; + redisQueueDepthGauge.set({}, queueDepth); + } catch (err) { + redisConnectedGauge.set({}, 0); + } + + // Circuit breaker state + try { + const { getAllBreakerStates } = require('../utils/circuitBreaker'); + const breakerStates = getAllBreakerStates(); + for (const [name, info] of Object.entries(breakerStates)) { + const stateVal = info.state === 'OPEN' ? 2 : info.state === 'HALF_OPEN' ? 1 : 0; + circuitBreakerStateGauge.set({ name, state: info.state }, stateVal); + } + } catch { + // If circuitBreaker registry is absent or empty, omit + } +} + +/** + * Check metrics endpoint authorization. + */ +function checkMetricsAuth(req) { + const isEnabled = process.env.METRICS_ENABLED !== 'false'; + if (!isEnabled) { + return { authorized: false, reason: 'DISABLED' }; + } + + const expectedToken = process.env.METRICS_AUTH_TOKEN; + const isDev = (process.env.NODE_ENV || 'development') === 'development'; + + // If no auth token is configured and we are in development, allow access + if (!expectedToken) { + if (isDev) return { authorized: true }; + // Outside development, protected by default if credentials are not configured + return { authorized: false, reason: 'UNAUTHORIZED' }; + } + + // Check Bearer Token or Basic Auth + const authHeader = req.get('authorization') || ''; + let providedToken = null; + + if (authHeader.startsWith('Bearer ')) { + providedToken = authHeader.slice(7).trim(); + } else if (authHeader.startsWith('Basic ')) { + try { + const creds = Buffer.from(authHeader.slice(6), 'base64').toString('utf8'); + const parts = creds.split(':'); + providedToken = parts[1] || parts[0]; + } catch { + providedToken = null; + } + } + + if (!providedToken) { + return { authorized: false, reason: 'UNAUTHORIZED' }; + } + + const providedBuf = Buffer.from(providedToken, 'utf8'); + const expectedBuf = Buffer.from(expectedToken, 'utf8'); + + if (providedBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(providedBuf, expectedBuf)) { + return { authorized: false, reason: 'UNAUTHORIZED' }; + } + + // Check IP allowlist if configured + const allowedIps = process.env.METRICS_ALLOWED_IPS + ? process.env.METRICS_ALLOWED_IPS.split(',').map((ip) => ip.trim()).filter(Boolean) + : []; + + if (allowedIps.length > 0) { + const clientIp = req.ip || req.socket?.remoteAddress || ''; + const isIpAllowed = allowedIps.some((allowed) => clientIp.includes(allowed)); + if (!isIpAllowed) { + return { authorized: false, reason: 'FORBIDDEN' }; + } + } + + return { authorized: true }; +} + +/** + * Express handler for GET /metrics in Prometheus text exposition format. + */ +async function metricsHandler(req, res) { + const authCheck = checkMetricsAuth(req); + if (!authCheck.authorized) { + if (authCheck.reason === 'DISABLED') { + return res.status(404).json({ + error: { code: 'NOT_FOUND', message: 'Metrics endpoint is disabled' }, + }); + } + if (authCheck.reason === 'FORBIDDEN') { + return res.status(403).json({ + error: { code: 'FORBIDDEN', message: 'Access forbidden from this IP' }, + }); + } + return res.status(401).json({ + error: { code: 'UNAUTHORIZED', message: 'Authentication required for metrics' }, + }); + } + + try { + updateProcessMetrics(); + await updateSubsystemMetrics(); + + const output = registry.serialize(); + res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); + return res.status(200).send(output); + } catch (err) { + logger.error('Failed to generate metrics', { error: err.message }); + return res.status(500).send('# Error generating metrics\n'); + } +} + +function incCounter(name, labels = {}) { + const m = registry.getMetric(name); + if (m && typeof m.inc === 'function') { + m.inc(labels); + } +} + +module.exports = { + registry, + metricsHandler, + requestMetricsMiddleware, + resolveRoutePattern, + checkMetricsAuth, + incCounter, + httpRequestsTotal, + httpRequestDuration, + httpRequestsInFlight, + priceCacheHits, + priceCacheMisses, + webhookDeliveriesTotal, + webhookDeliveryErrors, +}; diff --git a/src/metrics/registry.js b/src/metrics/registry.js new file mode 100644 index 0000000..c238c5b --- /dev/null +++ b/src/metrics/registry.js @@ -0,0 +1,251 @@ +'use strict'; + +/** + * Prometheus text exposition format generator and in-memory metrics registry (Issue #467). + * + * Implements: + * - Counter, Gauge, Histogram + * - HELP and TYPE lines + * - Label escaping + * - text/plain; version=0.0.4; charset=utf-8 output format + */ + +function escapeLabelValue(val) { + if (val === undefined || val === null) return ''; + return String(val) + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n'); +} + +function formatLabels(labels = {}) { + const entries = Object.entries(labels); + if (entries.length === 0) return ''; + const formatted = entries + .map(([k, v]) => `${k}="${escapeLabelValue(v)}"`) + .join(','); + return `{${formatted}}`; +} + +class Counter { + constructor(name, help) { + this.name = name; + this.help = help; + this.type = 'counter'; + this.values = new Map(); + } + + inc(labels = {}, value = 1) { + if (value < 0) return; + const key = JSON.stringify(labels); + const current = this.values.get(key) || { labels, value: 0 }; + current.value += value; + this.values.set(key, current); + } + + get(labels = {}) { + const key = JSON.stringify(labels); + return (this.values.get(key) || { value: 0 }).value; + } + + reset() { + this.values.clear(); + } + + serialize() { + const lines = []; + lines.push(`# HELP ${this.name} ${this.help}`); + lines.push(`# TYPE ${this.name} counter`); + if (this.values.size === 0) { + lines.push(`${this.name} 0`); + } else { + for (const entry of this.values.values()) { + lines.push(`${this.name}${formatLabels(entry.labels)} ${entry.value}`); + } + } + return lines.join('\n'); + } +} + +class Gauge { + constructor(name, help) { + this.name = name; + this.help = help; + this.type = 'gauge'; + this.values = new Map(); + } + + set(labels = {}, value) { + const key = JSON.stringify(labels); + this.values.set(key, { labels, value: Number(value) }); + } + + inc(labels = {}, value = 1) { + const key = JSON.stringify(labels); + const current = this.values.get(key) || { labels, value: 0 }; + current.value += value; + this.values.set(key, current); + } + + dec(labels = {}, value = 1) { + this.inc(labels, -value); + } + + get(labels = {}) { + const key = JSON.stringify(labels); + return (this.values.get(key) || { value: 0 }).value; + } + + reset() { + this.values.clear(); + } + + serialize() { + const lines = []; + lines.push(`# HELP ${this.name} ${this.help}`); + lines.push(`# TYPE ${this.name} gauge`); + if (this.values.size === 0) { + lines.push(`${this.name} 0`); + } else { + for (const entry of this.values.values()) { + lines.push(`${this.name}${formatLabels(entry.labels)} ${entry.value}`); + } + } + return lines.join('\n'); + } +} + +const DEFAULT_BUCKETS = [ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, +]; + +class Histogram { + constructor(name, help, options = {}) { + this.name = name; + this.help = help; + this.type = 'histogram'; + this.buckets = options.buckets || DEFAULT_BUCKETS; + this.entries = new Map(); + } + + observe(labels = {}, value) { + const val = Number(value); + const key = JSON.stringify(labels); + let entry = this.entries.get(key); + if (!entry) { + const bucketCounts = {}; + this.buckets.forEach((b) => { + bucketCounts[b] = 0; + }); + bucketCounts['+Inf'] = 0; + entry = { + labels, + sum: 0, + count: 0, + bucketCounts, + }; + this.entries.set(key, entry); + } + + entry.sum += val; + entry.count += 1; + + for (const b of this.buckets) { + if (val <= b) { + entry.bucketCounts[b] += 1; + } + } + entry.bucketCounts['+Inf'] += 1; + } + + reset() { + this.entries.clear(); + } + + serialize() { + const lines = []; + lines.push(`# HELP ${this.name} ${this.help}`); + lines.push(`# TYPE ${this.name} histogram`); + + if (this.entries.size === 0) { + lines.push(`${this.name}_count 0`); + lines.push(`${this.name}_sum 0`); + } else { + for (const entry of this.entries.values()) { + const baseLabels = entry.labels; + for (const b of this.buckets) { + const bLabels = { ...baseLabels, le: String(b) }; + lines.push( + `${this.name}_bucket${formatLabels(bLabels)} ${entry.bucketCounts[b]}` + ); + } + lines.push( + `${this.name}_bucket${formatLabels({ ...baseLabels, le: '+Inf' })} ${entry.bucketCounts['+Inf']}` + ); + lines.push( + `${this.name}_sum${formatLabels(baseLabels)} ${Number(entry.sum.toFixed(6))}` + ); + lines.push( + `${this.name}_count${formatLabels(baseLabels)} ${entry.count}` + ); + } + } + return lines.join('\n'); + } +} + +class Registry { + constructor() { + this.metrics = new Map(); + } + + register(metric) { + this.metrics.set(metric.name, metric); + return metric; + } + + counter(name, help) { + if (this.metrics.has(name)) return this.metrics.get(name); + const metric = new Counter(name, help); + return this.register(metric); + } + + gauge(name, help) { + if (this.metrics.has(name)) return this.metrics.get(name); + const metric = new Gauge(name, help); + return this.register(metric); + } + + histogram(name, help, options) { + if (this.metrics.has(name)) return this.metrics.get(name); + const metric = new Histogram(name, help, options); + return this.register(metric); + } + + getMetric(name) { + return this.metrics.get(name); + } + + reset() { + for (const metric of this.metrics.values()) { + metric.reset(); + } + } + + serialize() { + const blocks = []; + for (const metric of this.metrics.values()) { + blocks.push(metric.serialize()); + } + return blocks.join('\n') + '\n'; + } +} + +module.exports = { + Registry, + Counter, + Gauge, + Histogram, + escapeLabelValue, + formatLabels, +}; diff --git a/src/middleware/cors.js b/src/middleware/cors.js index 4597dd3..105f382 100644 --- a/src/middleware/cors.js +++ b/src/middleware/cors.js @@ -15,7 +15,7 @@ function buildCorsMiddleware(allowedOrigins) { }, credentials: true, methods: ['GET', 'POST', 'DELETE', 'PATCH', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-Token'], // Issue #344: preflight (OPTIONS) responses carry Access-Control-Max-Age // so browsers can cache the result instead of re-sending a preflight for // every cross-origin POST. Window defaults to 24h and is tunable via diff --git a/src/middleware/csrf.js b/src/middleware/csrf.js index 5ee84a0..d8bc1e8 100644 --- a/src/middleware/csrf.js +++ b/src/middleware/csrf.js @@ -1,34 +1,307 @@ -const AppError = require("../errors/AppError"); +'use strict'; /** - * CSRF defense-in-depth for state-changing webhook endpoints (#320). + * Global CSRF Protection Middleware (Issue #465). * - * `buildCorsMiddleware` already rejects cross-origin requests whose Origin - * header doesn't match `corsAllowedOrigins` — but it treats a request with - * NO Origin header as automatically allowed (`if (!origin || ...)`), and a - * plain HTML `