Skip to content

Commit f644dd7

Browse files
feat: security rate limiter with strict limits on POST MCDA analysis run (#12)
* feat: rate limiter in middleware * fix: page load and blockage after test * feat: hard security for POST job run analysis
1 parent db9d79d commit f644dd7

11 files changed

Lines changed: 949 additions & 6 deletions

File tree

.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,18 @@ DATABASE_URL="mysql://username:password@localhost:3306/database_name"
33
AUTH_SECRET="V5pDnymbX1zrXc7BTVsbWAL1wQCJYzfT"
44
# URL of the external MCDA analysis service — required for POST /api/v1/job-runs (returns 500 if unset)
55
JOB_RUN_IMPACT_ASSESS_ROUTE=
6+
JOB_RUN_IMPACT_API_KEY=X-Internal-API-Key
7+
8+
ODP_ADMIN_HOST_PRIVATE="http://localhost:8000"
9+
ODP_ADMIN_HOST_PUBLIC="http://localhost:8000"
10+
USER_CREATION_API_KEY="dev_env_api_key"
11+
SIGNUP_LAB_EDITOR_ROLE_ID=2
12+
SIGNUP_AUTO_ACTIVATE=true
13+
14+
# General rate limiting for all endpoints
15+
RATE_LIMIT_MAX_REQUESTS_PER_MINUTE=50
16+
RATE_LIMIT_BLOCK_DURATION_MINUTES=1
17+
18+
# Job run specific rate limiting (stricter for compute-intensive operations)
19+
JOB_RUN_RATE_LIMIT_MAX_REQUESTS_PER_MINUTE=3
20+
JOB_RUN_RATE_LIMIT_BLOCK_DURATION_MINUTES=5
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { RateLimiterController } from "./rate-limiter.controller";
3+
import type { IRateLimiter } from "../../lib/utils/rateLimiter";
4+
5+
const createRedirect = () => {
6+
return vi.fn((path: string) =>
7+
new Response(null, {
8+
status: 302,
9+
headers: { Location: path },
10+
}),
11+
);
12+
};
13+
14+
describe("RateLimiterController", () => {
15+
it("returns null for excluded static paths", () => {
16+
const limiter: IRateLimiter = {
17+
check: vi.fn(() => ({ allowed: false, retryAfterSeconds: 60 })),
18+
};
19+
const controller = new RateLimiterController(limiter);
20+
const redirect = createRedirect();
21+
22+
const response = controller.enforceRateLimit({
23+
request: new Request("http://localhost:4321/assets/app.js"),
24+
pathname: "/assets/app.js",
25+
search: "",
26+
redirect,
27+
});
28+
29+
expect(response).toBeNull();
30+
expect(limiter.check).not.toHaveBeenCalled();
31+
expect(redirect).not.toHaveBeenCalled();
32+
});
33+
34+
it("returns null when request is allowed", () => {
35+
const limiter: IRateLimiter = {
36+
check: vi.fn(() => ({ allowed: true })),
37+
};
38+
const controller = new RateLimiterController(limiter);
39+
const redirect = createRedirect();
40+
41+
const response = controller.enforceRateLimit({
42+
request: new Request("http://localhost:4321/dashboard", {
43+
headers: {
44+
"x-forwarded-for": "203.0.113.10",
45+
},
46+
}),
47+
pathname: "/dashboard",
48+
search: "",
49+
redirect,
50+
});
51+
52+
expect(response).toBeNull();
53+
expect(limiter.check).toHaveBeenCalledWith("203.0.113.10");
54+
});
55+
56+
it("returns JSON 429 response for blocked API requests", async () => {
57+
const limiter: IRateLimiter = {
58+
check: vi.fn(() => ({ allowed: false, retryAfterSeconds: 120 })),
59+
};
60+
const controller = new RateLimiterController(limiter);
61+
const redirect = createRedirect();
62+
63+
const response = controller.enforceRateLimit({
64+
request: new Request("http://localhost:4321/api/v1/labs", {
65+
headers: {
66+
"x-forwarded-for": "198.51.100.24",
67+
},
68+
}),
69+
pathname: "/api/v1/labs",
70+
search: "",
71+
redirect,
72+
});
73+
74+
expect(response).not.toBeNull();
75+
expect(response?.status).toBe(429);
76+
expect(response?.headers.get("Content-Type")).toContain("application/json");
77+
expect(response?.headers.get("Retry-After")).toBe("120");
78+
await expect(response?.json()).resolves.toEqual({
79+
error: "Service currently unavailable",
80+
});
81+
expect(redirect).not.toHaveBeenCalled();
82+
});
83+
84+
it("redirects blocked page requests to rate-limited page with original path", () => {
85+
const limiter: IRateLimiter = {
86+
check: vi.fn(() => ({ allowed: false, retryAfterSeconds: 300 })),
87+
};
88+
const controller = new RateLimiterController(limiter);
89+
const redirect = createRedirect();
90+
91+
const response = controller.enforceRateLimit({
92+
request: new Request("http://localhost:4321/tools/resources?tab=all"),
93+
pathname: "/tools/resources",
94+
search: "?tab=all",
95+
redirect,
96+
});
97+
98+
expect(response).not.toBeNull();
99+
expect(response?.status).toBe(302);
100+
expect(redirect).toHaveBeenCalledWith(
101+
"/rate-limited?from=%2Ftools%2Fresources%3Ftab%3Dall",
102+
);
103+
expect(response?.headers.get("Retry-After")).toBe("300");
104+
});
105+
106+
it("uses x-real-ip when x-forwarded-for is missing", () => {
107+
const limiter: IRateLimiter = {
108+
check: vi.fn(() => ({ allowed: true })),
109+
};
110+
const controller = new RateLimiterController(limiter);
111+
112+
controller.enforceRateLimit({
113+
request: new Request("http://localhost:4321/some-page", {
114+
headers: {
115+
"x-real-ip": "192.0.2.55",
116+
},
117+
}),
118+
pathname: "/some-page",
119+
search: "",
120+
redirect: createRedirect(),
121+
});
122+
123+
expect(limiter.check).toHaveBeenCalledWith("192.0.2.55");
124+
});
125+
126+
it("returns null for internal SSR requests (X-Internal-Request header)", () => {
127+
const limiter: IRateLimiter = {
128+
check: vi.fn(() => ({ allowed: false, retryAfterSeconds: 60 })),
129+
};
130+
const controller = new RateLimiterController(limiter);
131+
const redirect = createRedirect();
132+
133+
const response = controller.enforceRateLimit({
134+
request: new Request("http://localhost:4321/api/v1/labs", {
135+
headers: {
136+
"X-Internal-Request": "true",
137+
"x-forwarded-for": "203.0.113.10",
138+
},
139+
}),
140+
pathname: "/api/v1/labs",
141+
search: "",
142+
redirect,
143+
});
144+
145+
expect(response).toBeNull();
146+
expect(limiter.check).not.toHaveBeenCalled();
147+
expect(redirect).not.toHaveBeenCalled();
148+
});
149+
150+
describe("Job run specific rate limiting", () => {
151+
it("uses strict rate limiter for POST /api/v1/job-runs", () => {
152+
const generalLimiter: IRateLimiter = {
153+
check: vi.fn(() => ({ allowed: true })),
154+
};
155+
const jobRunLimiter: IRateLimiter = {
156+
check: vi.fn(() => ({ allowed: false, retryAfterSeconds: 300 })),
157+
};
158+
const controller = new RateLimiterController(
159+
generalLimiter,
160+
jobRunLimiter,
161+
);
162+
const redirect = createRedirect();
163+
164+
const response = controller.enforceRateLimit({
165+
request: new Request("http://localhost:4321/api/v1/job-runs", {
166+
method: "POST",
167+
headers: {
168+
"x-forwarded-for": "198.51.100.1",
169+
},
170+
}),
171+
pathname: "/api/v1/job-runs",
172+
search: "",
173+
redirect,
174+
});
175+
176+
expect(jobRunLimiter.check).toHaveBeenCalledWith("198.51.100.1");
177+
expect(generalLimiter.check).not.toHaveBeenCalled();
178+
expect(response).not.toBeNull();
179+
expect(response?.status).toBe(429);
180+
});
181+
182+
it("uses general rate limiter for GET /api/v1/job-runs", () => {
183+
const generalLimiter: IRateLimiter = {
184+
check: vi.fn(() => ({ allowed: true })),
185+
};
186+
const jobRunLimiter: IRateLimiter = {
187+
check: vi.fn(() => ({ allowed: false, retryAfterSeconds: 300 })),
188+
};
189+
const controller = new RateLimiterController(
190+
generalLimiter,
191+
jobRunLimiter,
192+
);
193+
const redirect = createRedirect();
194+
195+
const response = controller.enforceRateLimit({
196+
request: new Request("http://localhost:4321/api/v1/job-runs", {
197+
method: "GET",
198+
headers: {
199+
"x-forwarded-for": "198.51.100.1",
200+
},
201+
}),
202+
pathname: "/api/v1/job-runs",
203+
search: "",
204+
redirect,
205+
});
206+
207+
expect(generalLimiter.check).toHaveBeenCalledWith("198.51.100.1");
208+
expect(jobRunLimiter.check).not.toHaveBeenCalled();
209+
expect(response).toBeNull();
210+
});
211+
212+
it("uses general rate limiter for POST to other endpoints", () => {
213+
const generalLimiter: IRateLimiter = {
214+
check: vi.fn(() => ({ allowed: true })),
215+
};
216+
const jobRunLimiter: IRateLimiter = {
217+
check: vi.fn(() => ({ allowed: false, retryAfterSeconds: 300 })),
218+
};
219+
const controller = new RateLimiterController(
220+
generalLimiter,
221+
jobRunLimiter,
222+
);
223+
const redirect = createRedirect();
224+
225+
const response = controller.enforceRateLimit({
226+
request: new Request("http://localhost:4321/api/v1/labs", {
227+
method: "POST",
228+
headers: {
229+
"x-forwarded-for": "198.51.100.1",
230+
},
231+
}),
232+
pathname: "/api/v1/labs",
233+
search: "",
234+
redirect,
235+
});
236+
237+
expect(generalLimiter.check).toHaveBeenCalledWith("198.51.100.1");
238+
expect(jobRunLimiter.check).not.toHaveBeenCalled();
239+
expect(response).toBeNull();
240+
});
241+
242+
it("returns 429 with correct retry-after header for blocked job run requests", async () => {
243+
const generalLimiter: IRateLimiter = {
244+
check: vi.fn(() => ({ allowed: true })),
245+
};
246+
const jobRunLimiter: IRateLimiter = {
247+
check: vi.fn(() => ({ allowed: false, retryAfterSeconds: 300 })),
248+
};
249+
const controller = new RateLimiterController(
250+
generalLimiter,
251+
jobRunLimiter,
252+
);
253+
const redirect = createRedirect();
254+
255+
const response = controller.enforceRateLimit({
256+
request: new Request("http://localhost:4321/api/v1/job-runs", {
257+
method: "POST",
258+
headers: {
259+
"x-forwarded-for": "198.51.100.1",
260+
},
261+
}),
262+
pathname: "/api/v1/job-runs",
263+
search: "",
264+
redirect,
265+
});
266+
267+
expect(response).not.toBeNull();
268+
expect(response?.status).toBe(429);
269+
expect(response?.headers.get("Content-Type")).toContain(
270+
"application/json",
271+
);
272+
expect(response?.headers.get("Retry-After")).toBe("300");
273+
await expect(response?.json()).resolves.toEqual({
274+
error: "Service currently unavailable",
275+
});
276+
});
277+
});
278+
});

0 commit comments

Comments
 (0)