Skip to content

Commit 42103e3

Browse files
Merge pull request #4731 from verifywise-ai/4678-rate-limiter-verification-suite
4678 rate limiter verification suite
2 parents b6dd6dd + fd0c8be commit 42103e3

17 files changed

Lines changed: 1694 additions & 136 deletions

File tree

‎.github/workflows/backend-checks.yml‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ jobs:
8787
- name: Run tests
8888
run: npm test
8989

90+
# Blocking. The limiters are the only thing standing between /users/login and
91+
# unlimited password guesses, and `npm test` above cannot see them: it excludes
92+
# tests/integration/, and a limiter is invisible to a unit test with its store
93+
# mocked out. These three suites need no database and no environment, so they
94+
# run here rather than in the Postgres job. The wiring suite is excluded
95+
# because it builds the real app, which needs ENCRYPTION_KEY at module load —
96+
# it runs in tenant-isolation-tests instead.
97+
- name: Rate-limiter verification suite
98+
run: npm run test:ratelimit
99+
90100
- name: i18n audit (catch dictionary drift)
91101
run: npm run i18n:audit:strict
92102

@@ -188,7 +198,7 @@ jobs:
188198
# globalSetup; it fails closed without it. CI-only value.
189199
DB_APP_PASSWORD: test-app-role-password-for-ci
190200
run: |
191-
npm run test:integration -- --testPathPatterns='tests/integration/(workflow-audit-log|user-deletion-fks|reporting-rls-policies|report-run-visibility|framework-gap-workflow|report-scope-membership|report-scope-authorization|workflow-approval-gate|file-manager)\.test\.ts'
201+
npm run test:integration -- --testPathPatterns='tests/integration/(workflow-audit-log|user-deletion-fks|reporting-rls-policies|report-run-visibility|framework-gap-workflow|report-scope-membership|report-scope-authorization|workflow-approval-gate|file-manager|rate-limiting/limiter-wiring)\.test\.ts'
192202
193203
- name: Run deadline-summary smoke test
194204
env:

‎Servers/app.ts‎

Lines changed: 34 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import express, { RequestHandler } from "express";
22
import cors from "cors";
33
import helmet from "helmet";
4-
import rateLimit from "express-rate-limit";
54
import cookieParser from "cookie-parser";
65
import { csrfProtection } from "./middleware/csrf.middleware";
76

@@ -120,7 +119,7 @@ import virtualKeyProxyRoutes from "./routes/virtualKeyProxy.route";
120119
import internalRoutes from "./routes/internal.route";
121120
import superAdminRoutes from "./routes/superAdmin.route";
122121
import { i18nMiddleware } from "./middleware/i18n.middleware";
123-
import { generalApiLimiter } from "./middleware/rateLimit.middleware";
122+
import { generalApiLimiter, healthCheckLimiter } from "./middleware/rateLimit.middleware";
124123
import { sequelize } from "./database/db";
125124
import redisClient from "./database/redis";
126125
import ssoConfigRoutes from "./routes/ssoConfig.route";
@@ -192,57 +191,45 @@ export function createApp(preRoutesMiddleware?: RequestHandler[]): express.Appli
192191

193192
// Generous rate limiter for the health endpoint. Load-balancer probes are
194193
// still allowed, but the endpoint is capped to prevent abuse.
195-
const nodeEnv = (process.env.NODE_ENV ?? "").trim().toLowerCase();
196-
const isNonProduction = nodeEnv === "development" || nodeEnv === "test" || nodeEnv === "local";
197-
app.get(
198-
"/health",
199-
rateLimit({
200-
windowMs: 60 * 1000,
201-
max: isNonProduction ? 100000 : 1000,
202-
standardHeaders: true,
203-
legacyHeaders: false,
204-
message: "Too many health-check requests from this IP, please slow down",
205-
}),
206-
async (_req, res) => {
207-
const AI_GATEWAY_URL = process.env.AI_GATEWAY_URL || "http://localhost:8100";
208-
const checks: Record<string, { status: "ok" | "error"; error?: string }> = {};
194+
app.get("/health", healthCheckLimiter, async (_req, res) => {
195+
const AI_GATEWAY_URL = process.env.AI_GATEWAY_URL || "http://localhost:8100";
196+
const checks: Record<string, { status: "ok" | "error"; error?: string }> = {};
209197

210-
try {
211-
await sequelize.query("SELECT 1");
212-
checks.database = { status: "ok" };
213-
} catch (err: unknown) {
214-
checks.database = { status: "error", error: (err as Error).message };
215-
}
198+
try {
199+
await sequelize.query("SELECT 1");
200+
checks.database = { status: "ok" };
201+
} catch (err: unknown) {
202+
checks.database = { status: "error", error: (err as Error).message };
203+
}
216204

217-
try {
218-
const pong = await redisClient.ping();
219-
checks.redis =
220-
pong === "PONG"
221-
? { status: "ok" }
222-
: { status: "error", error: `Unexpected PING response: ${pong}` };
223-
} catch (err: unknown) {
224-
checks.redis = { status: "error", error: (err as Error).message };
225-
}
205+
try {
206+
const pong = await redisClient.ping();
207+
checks.redis =
208+
pong === "PONG"
209+
? { status: "ok" }
210+
: { status: "error", error: `Unexpected PING response: ${pong}` };
211+
} catch (err: unknown) {
212+
checks.redis = { status: "error", error: (err as Error).message };
213+
}
226214

215+
try {
216+
const controller = new AbortController();
217+
const timeout = setTimeout(() => controller.abort(), 5000);
227218
try {
228-
const controller = new AbortController();
229-
const timeout = setTimeout(() => controller.abort(), 5000);
230-
try {
231-
const gwRes = await fetch(`${AI_GATEWAY_URL}/health`, { signal: controller.signal });
232-
checks.ai_gateway = gwRes.ok
233-
? { status: "ok" }
234-
: { status: "error", error: `HTTP ${gwRes.status}` };
235-
} finally {
236-
clearTimeout(timeout);
237-
}
238-
} catch (err: unknown) {
239-
checks.ai_gateway = { status: "error", error: (err as Error).message };
219+
const gwRes = await fetch(`${AI_GATEWAY_URL}/health`, { signal: controller.signal });
220+
checks.ai_gateway = gwRes.ok
221+
? { status: "ok" }
222+
: { status: "error", error: `HTTP ${gwRes.status}` };
223+
} finally {
224+
clearTimeout(timeout);
240225
}
226+
} catch (err: unknown) {
227+
checks.ai_gateway = { status: "error", error: (err as Error).message };
228+
}
241229

242-
const allOk = Object.values(checks).every((c) => c.status === "ok");
243-
res.status(allOk ? 200 : 503).json({ status: allOk ? "ok" : "degraded", checks });
244-
},
245-
);
230+
const allOk = Object.values(checks).every((c) => c.status === "ok");
231+
res.status(allOk ? 200 : 503).json({ status: allOk ? "ok" : "degraded", checks });
232+
});
246233

247234
// Track every request: metrics + access log (shipped to the central
248235
// observability stack when monitoring is enabled). Mounted after /health so

‎Servers/extensions/slack/slack.route.ts‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
*/
1414

1515
import express from "express";
16-
import rateLimit from "express-rate-limit";
1716
import authenticateJWT from "../../middleware/auth.middleware";
1817
import { requireExtensionEnabled } from "../../middleware/requireExtensionEnabled.middleware";
1918
import {
@@ -32,20 +31,13 @@ router.use(requireExtensionEnabled("slack"));
3231

3332
// Rate limit the OAuth-exchange endpoint so a leaked JWT can't spam
3433
// Slack's OAuth API on our behalf.
35-
const createWorkspaceLimiter = rateLimit({
36-
windowMs: 60 * 60 * 1000,
37-
max: 10,
38-
message: {
39-
error:
40-
"Too many Slack workspace creation requests from this IP, please try again after an hour.",
41-
},
42-
});
34+
import { slackWorkspaceCreateLimiter } from "../../middleware/rateLimit.middleware";
4335

4436
// OAuth workspaces — exposed at /api/extensions/slack/oauth/workspaces to match
4537
// the extension's UI conventions. Handlers are shared with /api/slackWebhooks.
4638
router.get("/oauth/workspaces", getAllSlackWebhooks);
4739
router.get("/oauth/workspaces/:id", getSlackWebhookById);
48-
router.post("/oauth/workspaces", createWorkspaceLimiter, createNewSlackWebhook);
40+
router.post("/oauth/workspaces", slackWorkspaceCreateLimiter, createNewSlackWebhook);
4941
router.patch("/oauth/workspaces/:id", updateSlackWebhookById);
5042
router.delete("/oauth/workspaces/:id", deleteSlackWebhookById);
5143

‎Servers/middleware/rateLimit.middleware.ts‎

Lines changed: 117 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,28 @@
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
*/
191265
export 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);

‎Servers/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"test:unit": "jest --testPathIgnorePatterns=/tests/integration/ --testPathIgnorePatterns=/helpers/ --detectOpenHandles --forceExit",
77
"test:coverage": "jest --coverage --testPathIgnorePatterns=/tests/integration/ --testPathIgnorePatterns=/helpers/ --detectOpenHandles --forceExit",
88
"test:smoke": "jest --config jest.config.js --globalSetup=\"<rootDir>/tests/integration/globalSetup.js\" --testPathPatterns=deadline-summary --runInBand",
9+
"test:ratelimit": "jest --config jest.config.js --testMatch=\"**/tests/integration/rate-limiting/**/*.test.ts\" --testPathIgnorePatterns=/helpers/ --testPathIgnorePatterns=limiter-wiring --runInBand --forceExit",
910
"smoke:api-contract": "ts-node --transpile-only scripts/apiContractSmoke.ts",
1011
"generate:enum-manifest": "ts-node --transpile-only scripts/generateEnumManifest.ts",
1112
"check:enum-drift": "npm run generate:enum-manifest && ts-node --transpile-only scripts/checkEnumLabelDrift.ts",

‎Servers/routes/__tests__/integration/user.route.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,16 @@ jest.mock("../../../middleware/accessControl.middleware", () => ({
4848
default: jest.fn(() => (_req: any, _res: any, next: any) => next()),
4949
}));
5050

51+
// Every limiter this router mounts must be stubbed here. They all now come from
52+
// rateLimit.middleware (loginLimiter used to be declared inline in user.route.ts and
53+
// was covered by an express-rate-limit mock), so a limiter missing from this list
54+
// mounts `undefined` and the whole suite fails to load.
5155
jest.mock("../../../middleware/rateLimit.middleware", () => ({
5256
authLimiter: jest.fn((_req: any, _res: any, next: any) => next()),
57+
loginLimiter: jest.fn((_req: any, _res: any, next: any) => next()),
5358
tokenRefreshLimiter: jest.fn((_req: any, _res: any, next: any) => next()),
5459
}));
5560

56-
jest.mock("express-rate-limit", () =>
57-
jest.fn(() => (_req: unknown, _res: unknown, next: () => void) => next()),
58-
);
59-
6061
import userRoutes from "../../user.route";
6162

6263
function createUserTestApp(): express.Application {

0 commit comments

Comments
 (0)