mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +00:00
9480cc98bb
The hybrid rate-limit key generator bucketed authenticated requests by a username read from an unverified jwt.decode of the session cookie or Bearer JWT. One source could fragment the per-source cap by rotating a forged JWT with a varying username, minting a fresh bucket per value. Each forged request is still rejected at auth, so this is DoS amplification, not an auth bypass; the same class as the API-token vector hardened earlier. Verify the JWT signature against the cached signing secret before keying by username; forged, expired, or otherwise invalid credentials fall back to per-IP. Enforce strict bearer-over-cookie precedence so a present-but-invalid Bearer cannot be rescued into a valid cookie's bucket. The verification helper fails closed: any error degrades to per-IP rather than throwing out of the key generator.
245 lines
11 KiB
TypeScript
245 lines
11 KiB
TypeScript
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';
|
|
import { looksLikeApiToken } from '../utils/apiTokenFormat';
|
|
import { validateApiToken } from '../utils/apiTokenAuth';
|
|
import { DatabaseService } from '../services/DatabaseService';
|
|
|
|
// ── 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;
|
|
}
|
|
const bearer = auth.slice(7);
|
|
// Opaque API tokens are never node_proxy credentials, and they are not
|
|
// JWTs — short-circuit so `jwt.decode` is never invoked on them.
|
|
if (looksLikeApiToken(bearer)) {
|
|
cached._isNodeProxy = false;
|
|
return false;
|
|
}
|
|
try {
|
|
const decoded = jwt.decode(bearer) as { scope?: string } | null;
|
|
const result = decoded?.scope === 'node_proxy';
|
|
cached._isNodeProxy = result;
|
|
return result;
|
|
} catch {
|
|
cached._isNodeProxy = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify a session/bearer JWT against the cached signing secret and return its
|
|
* payload, or null when the secret is unset, the signature or expiry is invalid,
|
|
* or the settings read itself fails. `getGlobalSettings()` is a frozen in-memory
|
|
* cache (no per-call DB hit) and `jwt.verify` costs microseconds, so this is safe
|
|
* on the rate-limiter hot path. Verifying here is what stops one source from
|
|
* fragmenting the limiter by rotating a forged JWT with a varying username: a
|
|
* credential that does not verify yields no per-user key. Fails closed: any error
|
|
* returns null, so the caller degrades to per-IP keying rather than throwing out
|
|
* of the key generator.
|
|
*/
|
|
function verifiedJwtPayload(token: string): { username?: string; sub?: string } | null {
|
|
try {
|
|
const secret = DatabaseService.getInstance().getGlobalSettings().auth_jwt_secret;
|
|
if (!secret) return null;
|
|
return jwt.verify(token, secret) as { username?: string; sub?: string };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hybrid rate limit key: per-token / per-user for authenticated requests, IP
|
|
* otherwise. Mirrors authMiddleware's bearer-over-cookie precedence (auth.ts
|
|
* uses `bearerToken || cookieToken`) so the limiter keys off the same credential
|
|
* auth will use, and a JWT is trusted only once its signature verifies. When a
|
|
* Bearer header is present the cookie is never consulted (auth ignores it too),
|
|
* so a forged or expired Bearer cannot be rescued into a valid cookie's bucket.
|
|
* Anything that does not resolve to a live token or a verified username falls
|
|
* back to per-IP keying.
|
|
*/
|
|
export function rateLimitKeyGenerator(req: Request): string {
|
|
const auth = req.headers.authorization;
|
|
if (auth?.startsWith('Bearer ')) {
|
|
const bearer = auth.slice(7);
|
|
// Opaque API tokens get a per-token rate-limit budget, but ONLY when the
|
|
// bearer resolves to a real, active token. This runs before authMiddleware,
|
|
// so keying any token-shaped string by its own hash would let one source
|
|
// mint a fresh budget per forged value and fragment the limiter; anything
|
|
// that is not a live token therefore falls through to per-IP keying. The
|
|
// validated row is memoized on the request so authMiddleware reuses it
|
|
// without a second lookup (and a request crossing two limiters reuses it
|
|
// here too). Like the verified-JWT branches below, a validation failure
|
|
// degrades to per-IP keying rather than throwing out of the key generator.
|
|
if (looksLikeApiToken(bearer)) {
|
|
if (req._apiToken) return `user:sk:${req._apiToken.token_hash.slice(0, 16)}`;
|
|
try {
|
|
const validation = validateApiToken(bearer);
|
|
if (validation.ok) {
|
|
// Only ever memoize the row matching this request's bearer;
|
|
// authMiddleware trusts req._apiToken without re-checking the hash.
|
|
req._apiToken = validation.token;
|
|
return `user:sk:${validation.token.token_hash.slice(0, 16)}`;
|
|
}
|
|
} catch { /* fall through to IP */ }
|
|
return ipKeyGenerator(req.ip || 'unknown');
|
|
}
|
|
// Non-API Bearer JWT: key by the VERIFIED username (sub is a legacy
|
|
// fallback, now signature-gated, kept only for back-compat). A Bearer header
|
|
// present means auth ignores the cookie, so an unverified Bearer returns
|
|
// per-IP here rather than falling through to the cookie branch below.
|
|
const payload = verifiedJwtPayload(bearer);
|
|
if (payload?.username) return `user:${payload.username}`;
|
|
if (payload?.sub) return `user:${payload.sub}`;
|
|
return ipKeyGenerator(req.ip || 'unknown');
|
|
}
|
|
const cookie = req.cookies?.[COOKIE_NAME];
|
|
if (cookie) {
|
|
const payload = verifiedJwtPayload(cookie);
|
|
if (payload?.username) return `user:${payload.username}`;
|
|
}
|
|
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: 1000 attempts so the full E2E suite, which logs
|
|
// in per test and has outgrown a 100-attempt window, plus local tooling, are not blocked.
|
|
export const authRateLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: process.env.NODE_ENV === 'production' ? 5 : 1000,
|
|
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.' },
|
|
});
|
|
|
|
// Pilot enrollment limiter. Enrollment mints a JWT and writes a
|
|
// pilot_enrollments row, so it deserves a stricter ceiling than the global
|
|
// API limiter (200/min). Applied on the regenerate route directly and on
|
|
// `POST /api/nodes` only when the body resolves to pilot_agent mode (the
|
|
// limiter's `skip` function reads the parsed body).
|
|
export const enrollmentLimiter = rateLimit({
|
|
...rateLimitBase,
|
|
max: process.env.NODE_ENV === 'production' ? 10 : 100,
|
|
keyGenerator: rateLimitKeyGenerator,
|
|
message: { error: 'Too many enrollment requests. Please try again shortly.' },
|
|
skip: (req: Request) => {
|
|
// Only gate `POST /api/nodes` calls that actually create a pilot agent;
|
|
// proxy-mode node creation falls back to the global limiter. The
|
|
// dedicated `/pilot/enroll` route applies this limiter unconditionally,
|
|
// so the body check is bypassed there by skipping the skip when the
|
|
// path already targets enrollment.
|
|
if (req.path.endsWith('/pilot/enroll')) return false;
|
|
// Express.json() runs globally before route handlers in app.ts, so
|
|
// req.body is parsed by the time this fires. If a future refactor moves
|
|
// body parsing per-route the worst case is "limiter applies even to
|
|
// proxy-mode" rather than "limiter is bypassed entirely".
|
|
if (!req.body) return false;
|
|
const body = req.body as { mode?: string; type?: string };
|
|
return !(body.type === 'remote' && body.mode === 'pilot_agent');
|
|
},
|
|
});
|
|
|
|
// 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.' },
|
|
});
|