From 856a260a117213ee72bbc25aa48b732bc56d64b0 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 23 Apr 2026 18:22:26 -0400 Subject: [PATCH] refactor(backend): extract rate limiters, body parsing, and request gates (phase 1) (#731) Phase 1 of the index.ts monolith refactor. Extracts all non-auth middleware into focused modules. Routes and authMiddleware itself stay in index.ts for now; Phase 2 introduces createApp() and extracts authMiddleware. New modules: - middleware/rateLimiters.ts: globalApiLimiter, pollingLimiter, webhookTriggerLimiter, authRateLimiter, ssoRateLimiter, trivyInstallLimiter plus the hybrid rateLimitKeyGenerator and isNodeProxyRequest helper - middleware/jsonParser.ts: conditionalJsonParser that preserves the raw stream for remote-proxy forwarding (via helpers/proxyExemptPaths) - middleware/nodeContext.ts: nodeContextMiddleware - middleware/apiTokenScope.ts: enforceApiTokenScope + DEPLOY_ALLOWED_PATTERNS - middleware/authGate.ts: createAuthGate(authMiddleware) factory + auditLog. Factory takes authMiddleware as a dependency to avoid a circular import until Phase 2 extracts the auth module. - middleware/errorHandler.ts: central error handler that preserves err.status / err.expose from body-parser and other HTTP errors - helpers/routePatterns.ts: WEBHOOK_TRIGGER_RE shared by rateLimiters and authGate index.ts drops ~290 lines. Middleware registration order is unchanged. All 1278 tests pass. Code review fixes: typed ApiTokenScope in apiTokenScope.ts; replaced 2 em dashes with colons (Directive 18); added local CachedProxyFlagReq type alias for the node_proxy memoization cast; extracted deny() helper. --- backend/src/helpers/routePatterns.ts | 6 + backend/src/index.ts | 337 ++---------------------- backend/src/middleware/apiTokenScope.ts | 44 ++++ backend/src/middleware/authGate.ts | 66 +++++ backend/src/middleware/errorHandler.ts | 28 ++ backend/src/middleware/jsonParser.ts | 37 +++ backend/src/middleware/nodeContext.ts | 35 +++ backend/src/middleware/rateLimiters.ts | 156 +++++++++++ 8 files changed, 396 insertions(+), 313 deletions(-) create mode 100644 backend/src/helpers/routePatterns.ts create mode 100644 backend/src/middleware/apiTokenScope.ts create mode 100644 backend/src/middleware/authGate.ts create mode 100644 backend/src/middleware/errorHandler.ts create mode 100644 backend/src/middleware/jsonParser.ts create mode 100644 backend/src/middleware/nodeContext.ts create mode 100644 backend/src/middleware/rateLimiters.ts diff --git a/backend/src/helpers/routePatterns.ts b/backend/src/helpers/routePatterns.ts new file mode 100644 index 00000000..46828235 --- /dev/null +++ b/backend/src/helpers/routePatterns.ts @@ -0,0 +1,6 @@ +// Shared route-matching patterns used by multiple middleware modules. + +/** Matches webhook trigger paths: /webhooks//trigger. Used by the + * global rate limiter (skip) and the auth gate (skip): webhooks authenticate + * via HMAC, not session cookie. */ +export const WEBHOOK_TRIGGER_RE = /^\/webhooks\/\d+\/trigger$/; diff --git a/backend/src/index.ts b/backend/src/index.ts index 46131f73..c530d10c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,7 +2,6 @@ import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import compression from 'compression'; -import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; import helmet from 'helmet'; import WebSocket, { WebSocketServer } from 'ws'; import jwt from 'jsonwebtoken'; @@ -78,6 +77,19 @@ import { runPolicyGate, triggerPostDeployScan, } from './helpers/policyGate'; +import { + globalApiLimiter, + pollingLimiter, + webhookTriggerLimiter, + authRateLimiter, + ssoRateLimiter, + trivyInstallLimiter, +} from './middleware/rateLimiters'; +import { conditionalJsonParser } from './middleware/jsonParser'; +import { nodeContextMiddleware } from './middleware/nodeContext'; +import { createAuthGate, auditLog } from './middleware/authGate'; +import { enforceApiTokenScope } from './middleware/apiTokenScope'; +import { errorHandler } from './middleware/errorHandler'; /** * Invalidate the per-node caches affected by a stack/container mutation so @@ -210,206 +222,14 @@ app.use(compression({ // can read req.cookies for per-user rate limit bucketing. app.use(cookieParser()); -// ── Rate Limiting ───────────────────────────────────────────────────────────── -// -// Tiered rate limiting to prevent UX lockouts while maintaining security: -// Tier 0/1 (Polling): High-frequency GET endpoints exempt from global limit, -// with a 300/min safety net to prevent resource exhaustion. -// Tier W (Webhooks): CI/CD webhook triggers at 500/min (shared datacenter IPs). -// Tier 2 (Standard): All other endpoints at 200/min (raised from 100). -// Tier 3 (Auth): Strict brute-force protection (5-10 attempts / 15min). -// -// Enterprise adaptations: -// - Internal node-to-node traffic (node_proxy JWTs) bypasses all rate limiters. -// - Authenticated requests are keyed by user ID (not IP) to prevent shared -// NAT/VPN environments from pooling rate limit budgets. - -/** Read-only GET endpoints polled at high frequency by the dashboard/fleet UI. */ -const POLLING_EXEMPT_PATHS = new Set([ - '/meta', '/health', '/stats', '/system/stats', - '/stacks/statuses', '/metrics/historical', - '/auth/status', '/auth/sso/providers', '/license', -]); - -const WEBHOOK_TRIGGER_RE = /^\/webhooks\/\d+\/trigger$/; - -/** - * Returns true if the request bears a node_proxy Bearer token. - * Uses jwt.decode() (no signature verification) to avoid crypto overhead on the - * hot path; authMiddleware performs full verification downstream. Worst case for - * a forged token: it skips the rate limiter but is still rejected by auth. - * Result is memoized on the request object so the two sequential limiters - * don't repeat the work. - */ -function isNodeProxyRequest(req: Request): boolean { - const cached = (req as any)._isNodeProxy; - if (cached !== undefined) return cached; - const auth = req.headers.authorization; - if (!auth?.startsWith('Bearer ')) { - (req as any)._isNodeProxy = false; - return false; - } - try { - const decoded = jwt.decode(auth.slice(7)) as { scope?: string } | null; - const result = decoded?.scope === 'node_proxy'; - (req as any)._isNodeProxy = result; - return result; - } catch { - (req as any)._isNodeProxy = false; - return false; - } -} - -/** - * Hybrid rate limit key: uses the JWT username/sub claim for authenticated - * requests (per-user budgets) and falls back to IP for unauthenticated ones. - * Uses jwt.decode() (no verification) to avoid double-verification cost; - * authMiddleware handles signature checks downstream. - */ -function rateLimitKeyGenerator(req: Request): string { - const cookie = req.cookies?.[COOKIE_NAME]; - if (cookie) { - try { - const decoded = jwt.decode(cookie) as { username?: string } | null; - if (decoded?.username) return `user:${decoded.username}`; - } catch { /* fall through to IP */ } - } - const auth = req.headers.authorization; - if (auth?.startsWith('Bearer ')) { - try { - const decoded = jwt.decode(auth.slice(7)) as { username?: string; sub?: string } | null; - if (decoded?.username) return `user:${decoded.username}`; - if (decoded?.sub) return `user:${decoded.sub}`; - } catch { /* fall through to IP */ } - } - return ipKeyGenerator(req.ip || 'unknown'); -} - -/** Shared config for all rate limiters (1-minute window, standard headers). */ -const rateLimitBase = { - windowMs: 60 * 1000, - standardHeaders: true, - legacyHeaders: false, -} as const; - -// Tier 2: Global API rate limiter. Skips polling endpoints (Tier 0/1), webhook -// triggers (Tier W), and internal node-to-node traffic (node_proxy). -const globalApiLimiter = rateLimit({ - ...rateLimitBase, - max: process.env.NODE_ENV === 'production' - ? parseInt(process.env.API_RATE_LIMIT || '200', 10) - : 1000, - keyGenerator: rateLimitKeyGenerator, - message: { error: 'Too many requests. Please try again shortly.' }, - skip: (req: Request) => { - if (req.method === 'GET' && POLLING_EXEMPT_PATHS.has(req.path)) return true; - if (req.method === 'POST' && WEBHOOK_TRIGGER_RE.test(req.path)) return true; - if (isNodeProxyRequest(req)) return true; - return false; - }, -}); - +// Tiered rate limiting (see middleware/rateLimiters.ts for the full tier model). app.use('/api/', globalApiLimiter); - -// Tier 0/1: Polling safety net. Applies only to polling-exempt endpoints to -// prevent resource exhaustion from runaway or malicious polling. -const pollingLimiter = rateLimit({ - ...rateLimitBase, - max: process.env.NODE_ENV === 'production' - ? parseInt(process.env.API_POLLING_RATE_LIMIT || '300', 10) - : 3000, - keyGenerator: rateLimitKeyGenerator, - message: { error: 'Too many polling requests. Please try again shortly.' }, - skip: (req: Request) => { - if (isNodeProxyRequest(req)) return true; - return !(req.method === 'GET' && POLLING_EXEMPT_PATHS.has(req.path)); - }, -}); - app.use('/api/', pollingLimiter); -// Tier W: Webhook trigger limiter. Applied inline on the trigger route handler. -// CI/CD platforms (GitHub Actions, GitLab runners) often share datacenter IPs, -// so a higher ceiling prevents dropped deployments during burst activity. -const webhookTriggerLimiter = rateLimit({ - ...rateLimitBase, - max: process.env.NODE_ENV === 'production' ? 500 : 5000, - message: { error: 'Too many webhook triggers. Please try again shortly.' }, -}); - -// JSON body parser that also captures the raw bytes for HMAC verification. -const jsonParser = express.json({ - verify: (req, _res, buf) => { - (req as unknown as { rawBody: Buffer }).rawBody = buf; - }, -}); - -// Conditionally parse JSON bodies. Remote proxy requests must NOT have their body -// consumed here: express.json() drains the IncomingMessage stream into req.body -// and http-proxy then pipes an already-ended stream to the remote server. -// When Node.js pipes an ended readable it calls process.nextTick(dest.end()), -// which fires *before* the proxyReq socket event, so any attempt to write the -// body inside the proxyReq handler results in "write after end" and the request -// hangs. Solution: skip JSON parsing for remote-targeted /api/ requests so the -// raw stream flows through the proxy intact. -app.use((req: Request, res: Response, next: NextFunction): void => { - const nodeIdHeader = req.headers['x-node-id']; - if (nodeIdHeader) { - const nodeId = parseInt(nodeIdHeader as string, 10); - const node = NodeRegistry.getInstance().getNode(nodeId); - if ( - node?.type === 'remote' && - req.path.startsWith('/api/') && - !req.path.startsWith('/api/auth/') && - !req.path.startsWith('/api/nodes') && - !req.path.startsWith('/api/license') && - !req.path.startsWith('/api/fleet') && - !req.path.startsWith('/api/webhooks') && - !req.path.startsWith('/api/meta') - ) { - // Preserve body stream for proxy piping - next(); - return; - } - } - jsonParser(req, res, next); -}); - -// Node Context Middleware -const nodeContextMiddleware = (req: Request, res: Response, next: NextFunction) => { - const nodeIdHeader = req.headers['x-node-id'] as string; - const nodeIdQuery = req.query.nodeId as string; - if (nodeIdHeader) { - req.nodeId = parseInt(nodeIdHeader, 10); - } else if (nodeIdQuery) { - req.nodeId = parseInt(nodeIdQuery, 10); - } else { - req.nodeId = NodeRegistry.getInstance().getDefaultNodeId(); - } - - // Intercept requests to deleted nodes to prevent downstream errors. - // /api/nodes is intentionally exempt: it must always be reachable so the - // frontend can re-sync after a node is deleted (otherwise a stale x-node-id - // in localStorage causes an unrecoverable 404 loop). - if ( - req.path.startsWith('/api/') && - !req.path.startsWith('/api/auth/') && - !req.path.startsWith('/api/nodes') && - !req.path.startsWith('/api/license') && - !req.path.startsWith('/api/fleet') && - !req.path.startsWith('/api/webhooks') && - !req.path.startsWith('/api/meta') - ) { - const node = DatabaseService.getInstance().getNode(req.nodeId); - if (!node) { - res.status(404).json({ error: `Node with id ${req.nodeId} not found or was deleted.` }); - return; - } - } - - next(); -}; +// Parse JSON on local requests; preserve the raw stream for remote proxy forwarding. +app.use(conditionalJsonParser); +// Resolve req.nodeId and short-circuit requests to deleted nodes. app.use(nodeContextMiddleware); // WebSocket proxy server for forwarding remote node WS connections @@ -590,17 +410,6 @@ function clearMfaPendingCookie(res: Response, req: Request): void { res.clearCookie(MFA_PENDING_COOKIE_NAME, getCookieOptions(req)); } -// Rate limiter for auth endpoints - prevents brute-force attacks. -// Production: 5 attempts per 15-minute window per IP. -// Development: 100 attempts (so E2E tests and local tooling are not blocked). -const authRateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: process.env.NODE_ENV === 'production' ? 5 : 100, - standardHeaders: true, - legacyHeaders: false, - message: { error: 'Too many attempts. Please try again in 15 minutes.' }, -}); - // Captured at boot. Exposed via /api/health and /api/meta so the Fleet update overlay // can distinguish a brand-new process from the old one still mid-pull. const processStartedAt = Date.now(); @@ -848,14 +657,6 @@ app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, r // Seed SSO config from environment variables on startup SSOService.getInstance().seedFromEnv(); -const ssoRateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: process.env.NODE_ENV === 'production' ? 10 : 100, - standardHeaders: true, - legacyHeaders: false, - message: { error: 'Too many SSO attempts. Please try again later.' }, -}); - /** Derive the OAuth callback base URL from SSO_CALLBACK_URL or the request Host header, with injection validation. */ function getSSOBaseUrl(req: Request, res: Response): string | null { const host = req.get('host') || ''; @@ -1475,54 +1276,11 @@ app.put('/api/auth/mfa/sso-bypass', authMiddleware, (req: Request, res: Response } }); -// Apply authentication middleware to all /api/* routes except /api/auth/* -app.use('/api', (req: Request, res: Response, next: NextFunction): void => { - if (req.path.startsWith('/auth/') || /^\/webhooks\/\d+\/trigger$/.test(req.path)) { - next(); - return; - } - authMiddleware(req, res, next); -}); +// Auth gate on all /api/* routes (exempts /auth/* and webhook triggers). +app.use('/api', createAuthGate(authMiddleware)); -// Audit logging middleware - records all mutating API actions for Admiral accountability. -// Runs for POST/PUT/DELETE/PATCH on /api/* routes. Uses res.on('finish') to capture status code. -import { getAuditSummary } from './utils/audit-summaries'; - -app.use('/api', (req: Request, res: Response, next: NextFunction): void => { - if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) { - next(); - return; - } - - const username = req.user?.username || 'unknown'; - const nodeId = req.nodeId ?? null; - const forwarded = req.headers['x-forwarded-for']; - const xff = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : ''; - const ip = req.ip || xff || ''; - const apiPath = req.path; - - res.on('finish', () => { - try { - if (isDebugEnabled()) { - console.log(`[Audit:diag] ${req.method} /api${apiPath} by=${username} status=${res.statusCode} node=${nodeId ?? 'local'} ip=${ip}`); - } - DatabaseService.getInstance().insertAuditLog({ - timestamp: Date.now(), - username, - method: req.method, - path: `/api${apiPath}`, - status_code: res.statusCode, - node_id: nodeId, - ip_address: ip, - summary: getAuditSummary(req.method, apiPath), - }); - } catch (err) { - console.error('[Audit] Failed to write audit log:', err); - } - }); - - next(); -}); +// Audit-log every mutating /api/* action (POST/PUT/DELETE/PATCH). +app.use('/api', auditLog); // --- License Routes (local-only, never proxied) --- @@ -1538,48 +1296,6 @@ function isSqliteUniqueViolation(error: unknown): boolean { return error instanceof Error && 'code' in error && (error as { code: string }).code === 'SQLITE_CONSTRAINT_UNIQUE'; } -// Scope enforcement for API tokens - restricts which endpoints a token can reach. -const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [ - /^\/api\/stacks\/[^/]+\/deploy$/, - /^\/api\/stacks\/[^/]+\/down$/, - /^\/api\/stacks\/[^/]+\/restart$/, - /^\/api\/stacks\/[^/]+\/stop$/, - /^\/api\/stacks\/[^/]+\/start$/, - /^\/api\/stacks\/[^/]+\/update$/, -]; - -const enforceApiTokenScope = (req: Request, res: Response, next: NextFunction): void => { - const scope = req.apiTokenScope; - if (!scope) { next(); return; } // Not an API token request - if (isDebugEnabled()) console.log('[ApiTokenScope:diag]', req.method, req.path, 'scope:', scope); - if (scope === 'full-admin') { next(); return; } - - if (scope === 'read-only') { - if (req.method !== 'GET') { - if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied:', req.method, req.path, 'scope:', scope); - res.status(403).json({ error: 'API token scope "read-only" only allows GET requests.', code: 'SCOPE_DENIED' }); - return; - } - next(); - return; - } - - if (scope === 'deploy-only') { - if (req.method === 'GET') { next(); return; } - const fullPath = `/api${req.path}`; - if (req.method === 'POST' && DEPLOY_ALLOWED_PATTERNS.some(p => p.test(fullPath))) { - next(); - return; - } - if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied:', req.method, req.path, 'scope:', scope); - res.status(403).json({ error: 'API token scope "deploy-only" does not allow this action.', code: 'SCOPE_DENIED' }); - return; - } - - if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied: unknown scope', req.method, req.path, 'scope:', scope); - res.status(403).json({ error: 'Unknown API token scope.', code: 'SCOPE_DENIED' }); -}; - app.use('/api', enforceApiTokenScope); app.get('/api/license', (_req: Request, res: Response): void => { @@ -7516,14 +7232,6 @@ app.post('/api/auto-update/execute', authMiddleware, async (req: Request, res: R // Vulnerability Scanning Routes // ========================= -const trivyInstallLimiter = rateLimit({ - ...rateLimitBase, - windowMs: 10 * 60 * 1000, - max: process.env.NODE_ENV === 'production' ? 5 : 50, - keyGenerator: rateLimitKeyGenerator, - message: { error: 'Too many install requests. Try again later.' }, -}); - app.get('/api/security/trivy-status', authMiddleware, (_req: Request, res: Response) => { const svc = TrivyService.getInstance(); const installer = TrivyInstaller.getInstance(); @@ -8552,6 +8260,9 @@ if (process.env.NODE_ENV === 'production') { }); } +// Central error handler: must be registered after all routes and static. +app.use(errorHandler); + // Start server with migration let mfaReplayPurgeTimer: NodeJS.Timeout | null = null; diff --git a/backend/src/middleware/apiTokenScope.ts b/backend/src/middleware/apiTokenScope.ts new file mode 100644 index 00000000..4ed43089 --- /dev/null +++ b/backend/src/middleware/apiTokenScope.ts @@ -0,0 +1,44 @@ +import type { Request, Response, NextFunction, RequestHandler } from 'express'; +import { isDebugEnabled } from '../utils/debug'; +import type { ApiTokenScope } from '../services/DatabaseService'; + +// Scope enforcement for API tokens: restricts which endpoints a token can reach. +const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [ + /^\/api\/stacks\/[^/]+\/deploy$/, + /^\/api\/stacks\/[^/]+\/down$/, + /^\/api\/stacks\/[^/]+\/restart$/, + /^\/api\/stacks\/[^/]+\/stop$/, + /^\/api\/stacks\/[^/]+\/start$/, + /^\/api\/stacks\/[^/]+\/update$/, +]; + +const deny = (res: Response, req: Request, error: string, scope: ApiTokenScope | 'unknown'): void => { + if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied:', req.method, req.path, 'scope:', scope); + res.status(403).json({ error, code: 'SCOPE_DENIED' }); +}; + +export const enforceApiTokenScope: RequestHandler = (req: Request, res: Response, next: NextFunction): void => { + const scope = req.apiTokenScope; + if (!scope) { next(); return; } // Not an API token request + if (isDebugEnabled()) console.log('[ApiTokenScope:diag]', req.method, req.path, 'scope:', scope); + if (scope === 'full-admin') { next(); return; } + + if (scope === 'read-only') { + if (req.method === 'GET') { next(); return; } + deny(res, req, 'API token scope "read-only" only allows GET requests.', scope); + return; + } + + if (scope === 'deploy-only') { + if (req.method === 'GET') { next(); return; } + const fullPath = `/api${req.path}`; + if (req.method === 'POST' && DEPLOY_ALLOWED_PATTERNS.some(p => p.test(fullPath))) { + next(); + return; + } + deny(res, req, 'API token scope "deploy-only" does not allow this action.', scope); + return; + } + + deny(res, req, 'Unknown API token scope.', 'unknown'); +}; diff --git a/backend/src/middleware/authGate.ts b/backend/src/middleware/authGate.ts new file mode 100644 index 00000000..316b1a2f --- /dev/null +++ b/backend/src/middleware/authGate.ts @@ -0,0 +1,66 @@ +import type { Request, Response, NextFunction, RequestHandler } from 'express'; +import { DatabaseService } from '../services/DatabaseService'; +import { isDebugEnabled } from '../utils/debug'; +import { getAuditSummary } from '../utils/audit-summaries'; +import { WEBHOOK_TRIGGER_RE } from '../helpers/routePatterns'; + +/** + * Build the `/api/*` auth gate. Mounted at `/api`, so paths it sees are + * already stripped of the `/api` prefix. Exempts `/auth/*` (setup, login, + * SSO: handled by their own routes) and webhook triggers (authenticated + * via HMAC, not session). + * + * Takes `authMiddleware` as a dependency instead of importing it so this + * file does not pin the monolith's auth lifecycle. Phase 2 extracts + * `authMiddleware` into its own module. + */ +export function createAuthGate(authMiddleware: RequestHandler): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + if (req.path.startsWith('/auth/') || WEBHOOK_TRIGGER_RE.test(req.path)) { + next(); + return; + } + authMiddleware(req, res, next); + }; +} + +/** + * Audit-logging middleware. Records every mutating `/api/*` action for + * Admiral accountability. Mounted at `/api`. Uses `res.on('finish')` to + * capture the final status code. + */ +export const auditLog: RequestHandler = (req: Request, res: Response, next: NextFunction): void => { + if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) { + next(); + return; + } + + const username = req.user?.username || 'unknown'; + const nodeId = req.nodeId ?? null; + const forwarded = req.headers['x-forwarded-for']; + const xff = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : ''; + const ip = req.ip || xff || ''; + const apiPath = req.path; + + res.on('finish', () => { + try { + if (isDebugEnabled()) { + console.log(`[Audit:diag] ${req.method} /api${apiPath} by=${username} status=${res.statusCode} node=${nodeId ?? 'local'} ip=${ip}`); + } + DatabaseService.getInstance().insertAuditLog({ + timestamp: Date.now(), + username, + method: req.method, + path: `/api${apiPath}`, + status_code: res.statusCode, + node_id: nodeId, + ip_address: ip, + summary: getAuditSummary(req.method, apiPath), + }); + } catch (err) { + console.error('[Audit] Failed to write audit log:', err); + } + }); + + next(); +}; diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts new file mode 100644 index 00000000..7505e830 --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -0,0 +1,28 @@ +import type { Request, Response, NextFunction, ErrorRequestHandler } from 'express'; +import { getErrorMessage } from '../utils/errors'; + +interface HttpError { + status?: number; + statusCode?: number; + expose?: boolean; + message?: string; +} + +/** + * Central Express error handler. Preserves the status + message of HTTP errors + * thrown by upstream middleware (body-parser's 413 `PayloadTooLargeError`, + * CORS 403s, etc.) when the error is safe to expose; otherwise returns a + * generic 500. Mounted last so Express recognises the 4-argument signature. + */ +export const errorHandler: ErrorRequestHandler = (err: unknown, _req: Request, res: Response, next: NextFunction): void => { + console.error('[Error]', err); + if (res.headersSent) { + next(err); + return; + } + const e = (err ?? {}) as HttpError; + const status = e.statusCode ?? e.status ?? 500; + const expose = e.expose === true || status < 500; + const message = expose ? getErrorMessage(err, 'Request failed') : 'Internal server error'; + res.status(status).json({ error: message }); +}; diff --git a/backend/src/middleware/jsonParser.ts b/backend/src/middleware/jsonParser.ts new file mode 100644 index 00000000..ab4ff030 --- /dev/null +++ b/backend/src/middleware/jsonParser.ts @@ -0,0 +1,37 @@ +import express, { type Request, type Response, type NextFunction, type RequestHandler } from 'express'; +import { NodeRegistry } from '../services/NodeRegistry'; +import { isProxyExemptPath } from '../helpers/proxyExemptPaths'; + +// JSON body parser that also captures the raw bytes for HMAC verification. +// `rawBody` is part of the Express.Request augmentation (see types/express.ts); +// the cast is required because body-parser's `verify` signature types `req` as +// Node's IncomingMessage, not Express's Request. +const jsonParser = express.json({ + verify: (req, _res, buf) => { + (req as unknown as Request).rawBody = buf; + }, +}); + +/** + * Parse JSON on local requests but preserve the raw stream for remote proxy + * forwarding. + * + * `express.json()` drains the IncomingMessage into `req.body`. `http-proxy` + * then tries to pipe the already-ended stream to the upstream Sencho; Node + * schedules the destination `.end()` on `process.nextTick`, which fires before + * the `proxyReq` socket event, so any attempt to write the body later errors + * with "write after end" and the request hangs. Skipping JSON parsing for + * remote-targeted `/api/` paths keeps the stream intact. + */ +export const conditionalJsonParser: RequestHandler = (req: Request, res: Response, next: NextFunction): void => { + const nodeIdHeader = req.headers['x-node-id']; + if (nodeIdHeader) { + const nodeId = parseInt(nodeIdHeader as string, 10); + const node = NodeRegistry.getInstance().getNode(nodeId); + if (node?.type === 'remote' && req.path.startsWith('/api/') && !isProxyExemptPath(req.path)) { + next(); + return; + } + } + jsonParser(req, res, next); +}; diff --git a/backend/src/middleware/nodeContext.ts b/backend/src/middleware/nodeContext.ts new file mode 100644 index 00000000..710479b3 --- /dev/null +++ b/backend/src/middleware/nodeContext.ts @@ -0,0 +1,35 @@ +import type { Request, Response, NextFunction, RequestHandler } from 'express'; +import { NodeRegistry } from '../services/NodeRegistry'; +import { DatabaseService } from '../services/DatabaseService'; +import { isProxyExemptPath } from '../helpers/proxyExemptPaths'; + +/** + * Resolve `req.nodeId` from the `x-node-id` header, `?nodeId=` query param, + * or the default node. Returns 404 for requests targeting a deleted node so + * downstream handlers don't fail with obscure errors. + * + * `/api/nodes` is intentionally exempt so the frontend can re-sync after a + * node is deleted (otherwise a stale `x-node-id` in localStorage triggers an + * unrecoverable 404 loop). + */ +export const nodeContextMiddleware: RequestHandler = (req: Request, res: Response, next: NextFunction) => { + const nodeIdHeader = req.headers['x-node-id'] as string; + const nodeIdQuery = req.query.nodeId as string; + if (nodeIdHeader) { + req.nodeId = parseInt(nodeIdHeader, 10); + } else if (nodeIdQuery) { + req.nodeId = parseInt(nodeIdQuery, 10); + } else { + req.nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + } + + if (req.path.startsWith('/api/') && !isProxyExemptPath(req.path)) { + const node = DatabaseService.getInstance().getNode(req.nodeId); + if (!node) { + res.status(404).json({ error: `Node with id ${req.nodeId} not found or was deleted.` }); + return; + } + } + + next(); +}; diff --git a/backend/src/middleware/rateLimiters.ts b/backend/src/middleware/rateLimiters.ts new file mode 100644 index 00000000..69139516 --- /dev/null +++ b/backend/src/middleware/rateLimiters.ts @@ -0,0 +1,156 @@ +import type { Request } from 'express'; +import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; +import jwt from 'jsonwebtoken'; +import { COOKIE_NAME } from '../helpers/constants'; +import { WEBHOOK_TRIGGER_RE } from '../helpers/routePatterns'; + +// ── Rate Limiting ───────────────────────────────────────────────────────────── +// +// Tiered rate limiting to prevent UX lockouts while maintaining security: +// Tier 0/1 (Polling): High-frequency GET endpoints exempt from global limit, +// with a 300/min safety net to prevent resource exhaustion. +// Tier W (Webhooks): CI/CD webhook triggers at 500/min (shared datacenter IPs). +// Tier 2 (Standard): All other endpoints at 200/min. +// Tier 3 (Auth): Strict brute-force protection (5-10 attempts / 15min). +// +// Enterprise adaptations: +// - Internal node-to-node traffic (node_proxy JWTs) bypasses all rate limiters. +// - Authenticated requests are keyed by user ID (not IP) to prevent shared +// NAT/VPN environments from pooling rate limit budgets. + +/** Read-only GET endpoints polled at high frequency by the dashboard/fleet UI. */ +const POLLING_EXEMPT_PATHS = new Set([ + '/meta', '/health', '/stats', '/system/stats', + '/stacks/statuses', '/metrics/historical', + '/auth/status', '/auth/sso/providers', '/license', +]); + +type CachedProxyFlagReq = Request & { _isNodeProxy?: boolean }; + +/** + * True when the request bears a node_proxy Bearer token. Uses `jwt.decode()` + * (no signature verification) to keep the hot path cheap; `authMiddleware` + * verifies signatures downstream. Worst case for a forged token: it bypasses + * the rate limiter but is still rejected at auth. Result is memoized on the + * request object so sequential limiters don't repeat the work. + */ +function isNodeProxyRequest(req: Request): boolean { + const cached = (req as CachedProxyFlagReq); + if (cached._isNodeProxy !== undefined) return cached._isNodeProxy; + const auth = req.headers.authorization; + if (!auth?.startsWith('Bearer ')) { + cached._isNodeProxy = false; + return false; + } + try { + const decoded = jwt.decode(auth.slice(7)) as { scope?: string } | null; + const result = decoded?.scope === 'node_proxy'; + cached._isNodeProxy = result; + return result; + } catch { + cached._isNodeProxy = false; + return false; + } +} + +/** + * Hybrid rate limit key: JWT username/sub for authenticated requests + * (per-user budgets), IP otherwise. `jwt.decode()` avoids double-verification; + * `authMiddleware` handles signature checks downstream. + */ +function rateLimitKeyGenerator(req: Request): string { + const cookie = req.cookies?.[COOKIE_NAME]; + if (cookie) { + try { + const decoded = jwt.decode(cookie) as { username?: string } | null; + if (decoded?.username) return `user:${decoded.username}`; + } catch { /* fall through to IP */ } + } + const auth = req.headers.authorization; + if (auth?.startsWith('Bearer ')) { + try { + const decoded = jwt.decode(auth.slice(7)) as { username?: string; sub?: string } | null; + if (decoded?.username) return `user:${decoded.username}`; + if (decoded?.sub) return `user:${decoded.sub}`; + } catch { /* fall through to IP */ } + } + return ipKeyGenerator(req.ip || 'unknown'); +} + +/** Shared config for all per-minute limiters (1-minute window, standard headers). */ +const rateLimitBase = { + windowMs: 60 * 1000, + standardHeaders: true, + legacyHeaders: false, +} as const; + +// Tier 2: Global API rate limiter. Skips polling endpoints (Tier 0/1), webhook +// triggers (Tier W), and internal node-to-node traffic (node_proxy). +export const globalApiLimiter = rateLimit({ + ...rateLimitBase, + max: process.env.NODE_ENV === 'production' + ? parseInt(process.env.API_RATE_LIMIT || '200', 10) + : 1000, + keyGenerator: rateLimitKeyGenerator, + message: { error: 'Too many requests. Please try again shortly.' }, + skip: (req: Request) => { + if (req.method === 'GET' && POLLING_EXEMPT_PATHS.has(req.path)) return true; + if (req.method === 'POST' && WEBHOOK_TRIGGER_RE.test(req.path)) return true; + if (isNodeProxyRequest(req)) return true; + return false; + }, +}); + +// Tier 0/1: Polling safety net. Applies only to polling-exempt endpoints to +// prevent resource exhaustion from runaway or malicious polling. +export const pollingLimiter = rateLimit({ + ...rateLimitBase, + max: process.env.NODE_ENV === 'production' + ? parseInt(process.env.API_POLLING_RATE_LIMIT || '300', 10) + : 3000, + keyGenerator: rateLimitKeyGenerator, + message: { error: 'Too many polling requests. Please try again shortly.' }, + skip: (req: Request) => { + if (isNodeProxyRequest(req)) return true; + return !(req.method === 'GET' && POLLING_EXEMPT_PATHS.has(req.path)); + }, +}); + +// Tier W: Webhook trigger limiter. Applied inline on the trigger route handler. +// CI/CD platforms often share datacenter IPs, so a higher ceiling prevents +// dropped deployments during burst activity. +export const webhookTriggerLimiter = rateLimit({ + ...rateLimitBase, + max: process.env.NODE_ENV === 'production' ? 500 : 5000, + message: { error: 'Too many webhook triggers. Please try again shortly.' }, +}); + +// Tier 3: Auth endpoint limiter. 15-minute window to blunt brute-force attacks. +// Prod: 5 attempts/15min/IP. Dev: 100 attempts so E2E tests and local tooling are not blocked. +export const authRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: process.env.NODE_ENV === 'production' ? 5 : 100, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many attempts. Please try again in 15 minutes.' }, +}); + +// Tier 3: SSO flow limiter. Slightly more generous than authRateLimiter because +// OIDC callbacks can chain multiple round trips per user attempt. +export const ssoRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: process.env.NODE_ENV === 'production' ? 10 : 100, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many SSO attempts. Please try again later.' }, +}); + +// Trivy install/update limiter. Install + update are expensive (binary download, +// sha256 verification) so a 10-minute window prevents accidental thrashing. +export const trivyInstallLimiter = rateLimit({ + ...rateLimitBase, + windowMs: 10 * 60 * 1000, + max: process.env.NODE_ENV === 'production' ? 5 : 50, + keyGenerator: rateLimitKeyGenerator, + message: { error: 'Too many install requests. Try again later.' }, +});