Skip to content

Commit f55f008

Browse files
Merge pull request #211 from AikidoSec/patch-rate-limiting
Also match on context.url.pathname for rate limiting
2 parents 9d8d665 + c035a9c commit f55f008

File tree

5 files changed

+101
-15
lines changed

5 files changed

+101
-15
lines changed

library/agent/Context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export type Context = {
1313
body: unknown; // Can be an object, string or undefined (the body is parsed by something like body-parser)
1414
cookies: Record<string, string>;
1515
attackDetected?: boolean;
16+
consumedRateLimitForIP?: boolean;
17+
consumedRateLimitForUser?: boolean;
1618
user?: { id: string; name?: string };
1719
source: string;
1820
route: string | undefined;

library/helpers/tryParseURL.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as t from "tap";
2+
import { tryParseURL } from "./tryParseURL";
3+
4+
t.test("it returns undefined if invalid URL", async () => {
5+
const url = tryParseURL("invalid");
6+
t.same(url, undefined);
7+
});
8+
9+
t.test("it returns URL if valid URL", async () => {
10+
const url = tryParseURL("https://example.com");
11+
t.same(url, new URL("https://example.com/"));
12+
});

library/helpers/tryParseURL.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function tryParseURL(url: string) {
2+
try {
3+
return new URL(url);
4+
} catch {
5+
return undefined;
6+
}
7+
}

library/sources/Express.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ const agent = new Agent(
3636
enabled: true,
3737
},
3838
},
39+
{
40+
method: "GET",
41+
route: "/middleware-rate-limited",
42+
forceProtectionOff: false,
43+
rateLimiting: {
44+
windowSizeInMS: 2000,
45+
maxRequests: 3,
46+
enabled: true,
47+
},
48+
},
3949
],
4050
blockedUserIds: ["567"],
4151
configUpdatedAt: 0,
@@ -179,6 +189,10 @@ function getApp(userMiddleware = true) {
179189
res.send({ hello: "world" });
180190
});
181191

192+
app.use("/middleware-rate-limited", (req, res, next) => {
193+
res.send({ hello: "world" });
194+
});
195+
182196
return app;
183197
}
184198

@@ -390,3 +404,18 @@ t.test("it rate limits by user", async () => {
390404
const res2 = await request(getApp()).get("/user-rate-limited");
391405
t.same(res2.statusCode, 200);
392406
});
407+
408+
t.test("it rate limits by middleware", async () => {
409+
for (const _ of Array.from({ length: 3 })) {
410+
const res = await request(getApp(false)).get("/middleware-rate-limited");
411+
t.same(res.statusCode, 200);
412+
}
413+
414+
const res = await request(getApp(false)).get("/middleware-rate-limited");
415+
t.same(res.statusCode, 429);
416+
417+
await sleep(2000);
418+
419+
const res2 = await request(getApp(false)).get("/middleware-rate-limited");
420+
t.same(res2.statusCode, 200);
421+
});
Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,82 @@
11
import { Agent } from "../../agent/Agent";
22
import { Context } from "../../agent/Context";
3+
import { tryParseURL } from "../../helpers/tryParseURL";
34

45
export function shouldRateLimitRequest(context: Context, agent: Agent) {
5-
if (!context.route || !context.method) {
6-
return false;
7-
}
8-
9-
const rateLimiting = agent
10-
.getConfig()
11-
.getRateLimiting(context.method, context.route);
6+
const rateLimiting = getRateLimitingForContext(context, agent);
127

138
if (!rateLimiting) {
149
return false;
1510
}
1611

17-
if (context.remoteAddress) {
12+
const { config, route } = rateLimiting;
13+
14+
if (context.remoteAddress && !context.consumedRateLimitForIP) {
1815
const allowed = agent
1916
.getRateLimiter()
2017
.isAllowed(
21-
`${context.method}:${context.route}:ip:${context.remoteAddress}`,
22-
rateLimiting.windowSizeInMS,
23-
rateLimiting.maxRequests
18+
`${context.method}:${route}:ip:${context.remoteAddress}`,
19+
config.windowSizeInMS,
20+
config.maxRequests
2421
);
2522

23+
// This function is executed for every middleware and route handler
24+
// We want to count the request only once
25+
context.consumedRateLimitForIP = true;
26+
2627
if (!allowed) {
2728
return true;
2829
}
2930
}
3031

31-
if (context.user) {
32+
if (context.user && !context.consumedRateLimitForUser) {
3233
const allowed = agent
3334
.getRateLimiter()
3435
.isAllowed(
35-
`${context.method}:${context.route}:user:${context.user.id}`,
36-
rateLimiting.windowSizeInMS,
37-
rateLimiting.maxRequests
36+
`${context.method}:${route}:user:${context.user.id}`,
37+
config.windowSizeInMS,
38+
config.maxRequests
3839
);
3940

41+
// This function is executed for every middleware and route handler
42+
// We want to count the request only once
43+
context.consumedRateLimitForUser = true;
44+
4045
if (!allowed) {
4146
return true;
4247
}
4348
}
4449

4550
return false;
4651
}
52+
53+
function getRateLimitingForContext(context: Context, agent: Agent) {
54+
if (!context.method) {
55+
return undefined;
56+
}
57+
58+
if (context.route) {
59+
const rateLimiting = agent
60+
.getConfig()
61+
.getRateLimiting(context.method, context.route);
62+
63+
if (rateLimiting) {
64+
return { config: rateLimiting, route: context.route };
65+
}
66+
}
67+
68+
if (context.url) {
69+
const url = tryParseURL(context.url);
70+
if (url && url.pathname) {
71+
const rateLimiting = agent
72+
.getConfig()
73+
.getRateLimiting(context.method, url.pathname);
74+
75+
if (rateLimiting) {
76+
return { config: rateLimiting, route: url.pathname };
77+
}
78+
}
79+
}
80+
81+
return undefined;
82+
}

0 commit comments

Comments
 (0)