mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +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,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