mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +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 cors from 'cors';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import compression from 'compression';
|
import compression from 'compression';
|
||||||
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
|
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import WebSocket, { WebSocketServer } from 'ws';
|
import WebSocket, { WebSocketServer } from 'ws';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
@@ -78,6 +77,19 @@ import {
|
|||||||
runPolicyGate,
|
runPolicyGate,
|
||||||
triggerPostDeployScan,
|
triggerPostDeployScan,
|
||||||
} from './helpers/policyGate';
|
} 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
|
* 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.
|
// can read req.cookies for per-user rate limit bucketing.
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
// ── Rate Limiting ─────────────────────────────────────────────────────────────
|
// Tiered rate limiting (see middleware/rateLimiters.ts for the full tier model).
|
||||||
//
|
|
||||||
// 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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use('/api/', globalApiLimiter);
|
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);
|
app.use('/api/', pollingLimiter);
|
||||||
|
|
||||||
// Tier W: Webhook trigger limiter. Applied inline on the trigger route handler.
|
// Parse JSON on local requests; preserve the raw stream for remote proxy forwarding.
|
||||||
// CI/CD platforms (GitHub Actions, GitLab runners) often share datacenter IPs,
|
app.use(conditionalJsonParser);
|
||||||
// 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();
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Resolve req.nodeId and short-circuit requests to deleted nodes.
|
||||||
app.use(nodeContextMiddleware);
|
app.use(nodeContextMiddleware);
|
||||||
|
|
||||||
// WebSocket proxy server for forwarding remote node WS connections
|
// 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));
|
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
|
// 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.
|
// can distinguish a brand-new process from the old one still mid-pull.
|
||||||
const processStartedAt = Date.now();
|
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
|
// Seed SSO config from environment variables on startup
|
||||||
SSOService.getInstance().seedFromEnv();
|
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. */
|
/** 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 {
|
function getSSOBaseUrl(req: Request, res: Response): string | null {
|
||||||
const host = req.get('host') || '';
|
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/*
|
// Auth gate on all /api/* routes (exempts /auth/* and webhook triggers).
|
||||||
app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
app.use('/api', createAuthGate(authMiddleware));
|
||||||
if (req.path.startsWith('/auth/') || /^\/webhooks\/\d+\/trigger$/.test(req.path)) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
authMiddleware(req, res, next);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Audit logging middleware - records all mutating API actions for Admiral accountability.
|
// Audit-log every mutating /api/* action (POST/PUT/DELETE/PATCH).
|
||||||
// Runs for POST/PUT/DELETE/PATCH on /api/* routes. Uses res.on('finish') to capture status code.
|
app.use('/api', auditLog);
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- License Routes (local-only, never proxied) ---
|
// --- 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';
|
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.use('/api', enforceApiTokenScope);
|
||||||
|
|
||||||
app.get('/api/license', (_req: Request, res: Response): void => {
|
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
|
// 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) => {
|
app.get('/api/security/trivy-status', authMiddleware, (_req: Request, res: Response) => {
|
||||||
const svc = TrivyService.getInstance();
|
const svc = TrivyService.getInstance();
|
||||||
const installer = TrivyInstaller.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
|
// Start server with migration
|
||||||
let mfaReplayPurgeTimer: NodeJS.Timeout | null = null;
|
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