44 * Provides production-ready rate limiting for API endpoints to prevent abuse and DoS attacks.
55 * Uses express-rate-limit with IPv6-safe IP normalization.
66 *
7- * Rate Limiters:
8- * - fileOperationsLimiter: 100 requests/15min (for file uploads, downloads, deletions)
9- * - generalApiLimiter: 300 requests/min per IP (loose global ceiling mounted
10- * ahead of all route mounts in app.ts; stricter per-route limiters still
11- * apply on top)
12- * - authLimiter: 5 requests/15min (for login/register/reset to prevent brute force)
13- * - tokenRefreshLimiter: 60 requests/15min (for automatic access-token refresh)
7+ * Every limiter in the application is defined here and built through
8+ * `createRateLimiter`, so they all share one contract: draft-6 `RateLimit-*`
9+ * headers, no legacy `X-RateLimit-*`, and the canonical STATUS_CODE[429] body
10+ * `{ message: "Too Many Requests", data: <limiter message> }`. Defining a limiter
11+ * inline in a route file re-introduces express-rate-limit's defaults (legacy
12+ * headers, a non-standard body) and silently breaks that contract — see
13+ * tests/integration/rate-limiting/.
14+ *
15+ * Production limits:
16+ * - generalApiLimiter: 300/min — loose global ceiling mounted ahead of all route
17+ * mounts in app.ts; stricter per-route limiters still apply on top
18+ * - authLimiter: 5/15min — register, password reset, change password
19+ * - loginLimiter: 5/min — login and login-microsoft
20+ * - tokenRefreshLimiter: 60/15min — automatic access-token refresh
21+ * - fileOperationsLimiter: 100/15min — file uploads, downloads, deletions
22+ * - aiDetectionScanLimiter: 10/hour — expensive scans
23+ * - mrmIngestionLimiter: 5000/15min, keyed by token — machine-to-machine push
24+ * - webhookLimiter: 100/min — inbound signature-verified webhooks
25+ * - passwordResetEmailLimiter / inviteEmailLimiter / invitationResendLimiter:
26+ * 5/min each — outbound email
27+ * - slackWebhookCreateLimiter / slackWorkspaceCreateLimiter: 10/hour each
28+ * - healthCheckLimiter: 1000/min — probe endpoint
1429 *
1530 * The strict auth/refresh limits apply by default. They are relaxed ONLY when
1631 * NODE_ENV is an explicit dev/test value, so a single developer hammering
@@ -36,7 +51,7 @@ export const isNonProduction =
3651/**
3752 * Rate limit configuration with time window and request limits
3853 */
39- interface RateLimitConfig {
54+ export interface RateLimitConfig {
4055 windowMinutes : number ;
4156 maxRequests : number ;
4257 message : string ;
@@ -46,9 +61,17 @@ interface RateLimitConfig {
4661}
4762
4863/**
49- * Predefined rate limit configurations for different endpoint types
64+ * Builds the rate limit configurations for every endpoint type.
65+ *
66+ * Parameterised on `relaxed` rather than reading `isNonProduction` directly so the
67+ * PRODUCTION limits can be built inside a test process (which necessarily runs with
68+ * NODE_ENV=test). Without this, a brute-force test would silently exercise the
69+ * relaxed dev limits — 1000 auth attempts instead of 5 — and pass without ever
70+ * reaching the limit it claims to verify.
71+ *
72+ * @param relaxed - true to apply the loosened dev/test limits, false for production
5073 */
51- const RATE_LIMIT_CONFIGS : Record < string , RateLimitConfig > = {
74+ export const buildRateLimitConfigs = ( relaxed : boolean ) : Record < string , RateLimitConfig > => ( {
5275 fileOperations : {
5376 windowMinutes : 15 ,
5477 maxRequests : 100 ,
@@ -61,22 +84,22 @@ const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
6184 // a developer hammering localhost from one IP is not locked out.
6285 generalApi : {
6386 windowMinutes : 1 ,
64- maxRequests : isNonProduction ? 100000 : 300 ,
87+ maxRequests : relaxed ? 100000 : 300 ,
6588 message : "Too many requests from this IP, please slow down and retry" ,
6689 } ,
6790 auth : {
6891 windowMinutes : 15 ,
6992 // Strict by default to prevent brute force; relaxed only in explicit
7093 // dev/test so a single developer on one localhost IP is not locked out.
71- maxRequests : isNonProduction ? 1000 : 5 ,
94+ maxRequests : relaxed ? 1000 : 5 ,
7295 message : "Too many authentication attempts from this IP, please try again after 15 minutes" ,
7396 } ,
7497 // Token refresh happens automatically and legitimately many times in a normal
7598 // session, so it gets its own generous limit rather than sharing the strict
7699 // brute-force limiter. It still requires a valid refresh-token cookie.
77100 tokenRefresh : {
78101 windowMinutes : 15 ,
79- maxRequests : isNonProduction ? 1000 : 60 ,
102+ maxRequests : relaxed ? 1000 : 60 ,
80103 message : "Too many token refresh attempts from this IP, please try again after 15 minutes" ,
81104 } ,
82105 aiDetectionScan : {
@@ -93,7 +116,7 @@ const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
93116 // runaway token must not 429 every other tenant on the same egress IP.
94117 mrmIngestion : {
95118 windowMinutes : 15 ,
96- maxRequests : isNonProduction ? 100000 : 5000 ,
119+ maxRequests : relaxed ? 100000 : 5000 ,
97120 message : "Too many metric ingestion requests for this token, please slow down and retry" ,
98121 keyGenerator : ( req ) => {
99122 const tokenId = ( req as { mrmIngestionToken ?: { tokenId ?: number } } ) . mrmIngestionToken
@@ -106,10 +129,61 @@ const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
106129 } ,
107130 webhook : {
108131 windowMinutes : 1 ,
109- maxRequests : isNonProduction ? 100000 : 100 ,
132+ maxRequests : relaxed ? 100000 : 100 ,
110133 message : "Too many webhook requests from this IP, please slow down and retry" ,
111134 } ,
112- } ;
135+ // Brute-force control on the actual login endpoints. Separate from `auth`
136+ // (which guards register/reset/change-password) because the window is shorter:
137+ // a credential-stuffing run is fast, and a one-minute lockout costs a real user
138+ // far less than fifteen. Relaxed in explicit dev/test so the E2E suite's
139+ // repeated UI logins from one localhost IP are not blocked.
140+ login : {
141+ windowMinutes : 1 ,
142+ maxRequests : relaxed ? 1000 : 5 ,
143+ message : "Too many login attempts from this IP, please try again after a minute" ,
144+ } ,
145+ // Outbound-email endpoints. These are not relaxed in dev/test: the cost being
146+ // controlled is sending mail to a third party, which is just as real locally.
147+ passwordResetEmail : {
148+ windowMinutes : 1 ,
149+ maxRequests : 5 ,
150+ message : "Too many password reset requests from this IP, please try again later" ,
151+ } ,
152+ inviteEmail : {
153+ windowMinutes : 1 ,
154+ maxRequests : 5 ,
155+ message : "Too many invite requests from this IP, please try again later" ,
156+ } ,
157+ invitationResend : {
158+ windowMinutes : 1 ,
159+ maxRequests : 5 ,
160+ message : "Too many resend requests from this IP, please try again later" ,
161+ } ,
162+ slackWebhookCreate : {
163+ windowMinutes : 60 ,
164+ maxRequests : 10 ,
165+ message : "Too many webhook creation requests from this IP, please try again after an hour" ,
166+ } ,
167+ slackWorkspaceCreate : {
168+ windowMinutes : 60 ,
169+ maxRequests : 10 ,
170+ message :
171+ "Too many Slack workspace creation requests from this IP, please try again after an hour" ,
172+ } ,
173+ // Load-balancer and uptime probes are legitimately frequent, so this ceiling is
174+ // generous — it exists to stop /health being used as a free amplification
175+ // endpoint, not to throttle monitoring.
176+ healthCheck : {
177+ windowMinutes : 1 ,
178+ maxRequests : relaxed ? 100000 : 1000 ,
179+ message : "Too many health-check requests from this IP, please slow down" ,
180+ } ,
181+ } ) ;
182+
183+ /**
184+ * The configurations the running process actually uses, resolved once from NODE_ENV.
185+ */
186+ export const RATE_LIMIT_CONFIGS = buildRateLimitConfigs ( isNonProduction ) ;
113187
114188/**
115189 * Creates a standardized rate limit error handler
@@ -189,3 +263,30 @@ export const mrmIngestionLimiter = createRateLimiter(RATE_LIMIT_CONFIGS.mrmInges
189263 * bounded so a misconfigured or malicious sender cannot flood the system.
190264 */
191265export const webhookLimiter = createRateLimiter ( RATE_LIMIT_CONFIGS . webhook ) ;
266+
267+ /**
268+ * Brute-force limiter for POST /users/login and /users/login-microsoft.
269+ * Tighter window than authLimiter because credential stuffing is fast and a
270+ * one-minute lockout is cheap for a legitimate user who mistyped a password.
271+ */
272+ export const loginLimiter = createRateLimiter ( RATE_LIMIT_CONFIGS . login ) ;
273+
274+ /** Limiter for the password-reset email endpoint (POST /mail/reset-password). */
275+ export const passwordResetEmailLimiter = createRateLimiter ( RATE_LIMIT_CONFIGS . passwordResetEmail ) ;
276+
277+ /** Limiter for the user-invite email endpoint (POST /mail/invite). */
278+ export const inviteEmailLimiter = createRateLimiter ( RATE_LIMIT_CONFIGS . inviteEmail ) ;
279+
280+ /** Limiter for re-sending an invitation (POST /invitations/:id/resend). */
281+ export const invitationResendLimiter = createRateLimiter ( RATE_LIMIT_CONFIGS . invitationResend ) ;
282+
283+ /** Limiter for creating a Slack webhook (POST /slack-webhooks). */
284+ export const slackWebhookCreateLimiter = createRateLimiter ( RATE_LIMIT_CONFIGS . slackWebhookCreate ) ;
285+
286+ /** Limiter for connecting a Slack workspace (POST /extensions/slack/oauth/workspaces). */
287+ export const slackWorkspaceCreateLimiter = createRateLimiter (
288+ RATE_LIMIT_CONFIGS . slackWorkspaceCreate ,
289+ ) ;
290+
291+ /** Generous limiter for the GET /health probe endpoint. */
292+ export const healthCheckLimiter = createRateLimiter ( RATE_LIMIT_CONFIGS . healthCheck ) ;
0 commit comments