mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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.' },
|
||||
});
|
||||
Reference in New Issue
Block a user