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.
This commit is contained in:
Anso
2026-04-23 18:22:26 -04:00
committed by GitHub
parent 929e2fa6b1
commit 856a260a11
8 changed files with 396 additions and 313 deletions
+6
View File
@@ -0,0 +1,6 @@
// Shared route-matching patterns used by multiple middleware modules.
/** Matches webhook trigger paths: /webhooks/<numeric id>/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$/;
+24 -313
View File
@@ -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;
+44
View File
@@ -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');
};
+66
View File
@@ -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();
};
+28
View File
@@ -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 });
};
+37
View File
@@ -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);
};
+35
View File
@@ -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();
};
+156
View File
@@ -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.' },
});