Skip to content

Commit 35705b3

Browse files
Merge pull request #232 from AikidoSec/rate-limit-ip
Rate limit by IP + build route from URL
2 parents 5aee228 + b2660cf commit 35705b3

17 files changed

+477
-121
lines changed

library/agent/Agent.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,10 @@ export class Agent {
421421
this.routes.addRoute(method, path);
422422
}
423423

424+
getRoutes() {
425+
return this.routes;
426+
}
427+
424428
async flushStats(timeoutInMS: number) {
425429
this.statistics.forceCompress();
426430
await this.sendHeartbeat(timeoutInMS);
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import * as t from "tap";
2+
import { buildRouteFromURL } from "./buildRouteFromURL";
3+
4+
t.test("it returns undefined for invalid URLs", async () => {
5+
t.same(buildRouteFromURL(""), undefined);
6+
t.same(buildRouteFromURL("http"), undefined);
7+
});
8+
9+
t.test("it returns / for root URLs", async () => {
10+
t.same(buildRouteFromURL("/"), "/");
11+
t.same(buildRouteFromURL("http://localhost/"), "/");
12+
});
13+
14+
t.test("it replaces numbers", async () => {
15+
t.same(buildRouteFromURL("/posts/3"), "/posts/:number");
16+
t.same(buildRouteFromURL("http://localhost/posts/3"), "/posts/:number");
17+
t.same(buildRouteFromURL("http://localhost/posts/3/"), "/posts/:number");
18+
t.same(
19+
buildRouteFromURL("http://localhost/posts/3/comments/10"),
20+
"/posts/:number/comments/:number"
21+
);
22+
t.same(
23+
buildRouteFromURL("/blog/2023/05/great-article"),
24+
"/blog/:number/:number/great-article"
25+
);
26+
});
27+
28+
t.test("it replaces dates", async () => {
29+
t.same(buildRouteFromURL("/posts/2023-05-01"), "/posts/:date");
30+
t.same(buildRouteFromURL("/posts/2023-05-01/"), "/posts/:date");
31+
t.same(
32+
buildRouteFromURL("/posts/2023-05-01/comments/2023-05-01"),
33+
"/posts/:date/comments/:date"
34+
);
35+
t.same(buildRouteFromURL("/posts/01-05-2023"), "/posts/:date");
36+
});
37+
38+
t.test("it ignores comma numbers", async () => {
39+
t.same(buildRouteFromURL("/posts/3,000"), "/posts/3,000");
40+
});
41+
42+
t.test("it ignores API version numbers", async () => {
43+
t.same(buildRouteFromURL("/v1/posts/3"), "/v1/posts/:number");
44+
});
45+
46+
t.test("it replaces UUIDs v1", async () => {
47+
t.same(
48+
buildRouteFromURL("/posts/d9428888-122b-11e1-b85c-61cd3cbb3210"),
49+
"/posts/:uuid"
50+
);
51+
});
52+
53+
t.test("it replaces UUIDs v2", async () => {
54+
t.same(
55+
buildRouteFromURL("/posts/000003e8-2363-21ef-b200-325096b39f47"),
56+
"/posts/:uuid"
57+
);
58+
});
59+
60+
t.test("it replaces UUIDs v3", async () => {
61+
t.same(
62+
buildRouteFromURL("/posts/a981a0c2-68b1-35dc-bcfc-296e52ab01ec"),
63+
"/posts/:uuid"
64+
);
65+
});
66+
67+
t.test("it replaces UUIDs v4", async () => {
68+
t.same(
69+
buildRouteFromURL("/posts/109156be-c4fb-41ea-b1b4-efe1671c5836"),
70+
"/posts/:uuid"
71+
);
72+
});
73+
74+
t.test("it replaces UUIDs v5", async () => {
75+
t.same(
76+
buildRouteFromURL("/posts/90123e1c-7512-523e-bb28-76fab9f2f73d"),
77+
"/posts/:uuid"
78+
);
79+
});
80+
81+
t.test("it replaces UUIDs v6", async () => {
82+
t.same(
83+
buildRouteFromURL("/posts/1ef21d2f-1207-6660-8c4f-419efbd44d48"),
84+
"/posts/:uuid"
85+
);
86+
});
87+
88+
t.test("it replaces UUIDs v7", async () => {
89+
t.same(
90+
buildRouteFromURL("/posts/017f22e2-79b0-7cc3-98c4-dc0c0c07398f"),
91+
"/posts/:uuid"
92+
);
93+
});
94+
95+
t.test("it replaces UUIDs v8", async () => {
96+
t.same(
97+
buildRouteFromURL("/posts/0d8f23a0-697f-83ae-802e-48f3756dd581"),
98+
"/posts/:uuid"
99+
);
100+
});
101+
102+
t.test("it ignores invalid UUIDs", async () => {
103+
t.same(
104+
buildRouteFromURL("/posts/00000000-0000-1000-6000-000000000000"),
105+
"/posts/00000000-0000-1000-6000-000000000000"
106+
);
107+
});
108+
109+
t.test("it ignores strings", async () => {
110+
t.same(buildRouteFromURL("/posts/abc"), "/posts/abc");
111+
});

library/helpers/buildRouteFromURL.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { tryParseURLPath } from "./tryParseURLPath";
2+
3+
const UUID =
4+
/(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i;
5+
const NUMBER = /^\d+$/;
6+
const DATE = /^\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4}$/;
7+
8+
export function buildRouteFromURL(url: string) {
9+
const path = tryParseURLPath(url);
10+
11+
if (!path) {
12+
return undefined;
13+
}
14+
15+
const route = path.split("/").map(replaceURLSegmentWithParam).join("/");
16+
17+
if (route === "/") {
18+
return "/";
19+
}
20+
21+
if (route.endsWith("/")) {
22+
return route.slice(0, -1);
23+
}
24+
25+
return route;
26+
}
27+
28+
function replaceURLSegmentWithParam(segment: string) {
29+
if (NUMBER.test(segment)) {
30+
return ":number";
31+
}
32+
33+
if (UUID.test(segment)) {
34+
return ":uuid";
35+
}
36+
37+
if (DATE.test(segment)) {
38+
return ":date";
39+
}
40+
41+
return segment;
42+
}

library/helpers/matchEndpoint.test.ts

Lines changed: 45 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import * as t from "tap";
22
import { Context } from "../agent/Context";
3+
import { buildRouteFromURL } from "./buildRouteFromURL";
34
import { matchEndpoint } from "./matchEndpoint";
45

6+
const url = "http://localhost:4000/posts/3";
57
const context: Context = {
68
remoteAddress: "::1",
79
method: "POST",
8-
url: "http://localhost:4000/posts/3",
10+
url: url,
911
query: {},
1012
headers: {},
1113
body: undefined,
1214
cookies: {},
1315
routeParams: {},
1416
source: "express",
15-
route: "/posts/:id",
17+
route: buildRouteFromURL(url),
1618
};
1719

1820
t.test("invalid URL and no route", async () => {
@@ -42,48 +44,77 @@ t.test("it returns endpoint based on route", async () => {
4244
matchEndpoint(context, [
4345
{
4446
method: "POST",
45-
route: "/posts/:id",
47+
route: "/posts/:number",
4648
rateLimiting: { enabled: true, maxRequests: 10, windowSizeInMS: 1000 },
4749
forceProtectionOff: false,
4850
},
4951
]),
5052
{
5153
endpoint: {
5254
method: "POST",
53-
route: "/posts/:id",
55+
route: "/posts/:number",
5456
rateLimiting: { enabled: true, maxRequests: 10, windowSizeInMS: 1000 },
5557
forceProtectionOff: false,
5658
},
57-
route: "/posts/:id",
59+
route: "/posts/:number",
5860
}
5961
);
6062
});
6163

62-
t.test("it returns endpoint based on url", async () => {
64+
t.test("it returns endpoint based on relative url", async () => {
65+
t.same(
66+
matchEndpoint(
67+
{ ...context, route: buildRouteFromURL("/posts/3"), url: "/posts/3" },
68+
[
69+
{
70+
method: "POST",
71+
route: "/posts/:number",
72+
rateLimiting: {
73+
enabled: true,
74+
maxRequests: 10,
75+
windowSizeInMS: 1000,
76+
},
77+
forceProtectionOff: false,
78+
},
79+
]
80+
),
81+
{
82+
endpoint: {
83+
method: "POST",
84+
route: "/posts/:number",
85+
rateLimiting: { enabled: true, maxRequests: 10, windowSizeInMS: 1000 },
86+
forceProtectionOff: false,
87+
},
88+
route: "/posts/:number",
89+
}
90+
);
91+
});
92+
93+
t.test("it returns endpoint based on wildcard", async () => {
6394
t.same(
6495
matchEndpoint({ ...context, route: undefined }, [
6596
{
66-
method: "POST",
67-
route: "/posts/3",
97+
method: "*",
98+
route: "/posts/*",
6899
rateLimiting: { enabled: true, maxRequests: 10, windowSizeInMS: 1000 },
69100
forceProtectionOff: false,
70101
},
71102
]),
72103
{
73104
endpoint: {
74-
method: "POST",
75-
route: "/posts/3",
105+
method: "*",
106+
route: "/posts/*",
76107
rateLimiting: { enabled: true, maxRequests: 10, windowSizeInMS: 1000 },
77108
forceProtectionOff: false,
78109
},
79-
route: "/posts/3",
110+
route: "/posts/*",
80111
}
81112
);
82113
});
83114

84-
t.test("it returns endpoint based on wildcard", async () => {
115+
t.test("it returns endpoint based on wildcard with relative URL", async () => {
85116
t.same(
86-
matchEndpoint({ ...context, route: undefined }, [
117+
matchEndpoint({ ...context, route: undefined, url: "/posts/3" }, [
87118
{
88119
method: "*",
89120
route: "/posts/*",
@@ -146,50 +177,6 @@ t.test("it favors more specific wildcard", async () => {
146177
);
147178
});
148179

149-
t.test("it does not check whether rate limiting is enabled", async () => {
150-
t.same(
151-
matchEndpoint({ ...context, route: undefined }, [
152-
{
153-
method: "POST",
154-
route: "/posts/3",
155-
rateLimiting: { enabled: false, maxRequests: 10, windowSizeInMS: 1000 },
156-
forceProtectionOff: false,
157-
},
158-
]),
159-
{
160-
endpoint: {
161-
method: "POST",
162-
route: "/posts/3",
163-
rateLimiting: { enabled: false, maxRequests: 10, windowSizeInMS: 1000 },
164-
forceProtectionOff: false,
165-
},
166-
route: "/posts/3",
167-
}
168-
);
169-
});
170-
171-
t.test("it does not check whether force protection is off", async () => {
172-
t.same(
173-
matchEndpoint({ ...context, route: undefined }, [
174-
{
175-
method: "POST",
176-
route: "/posts/3",
177-
rateLimiting: { enabled: true, maxRequests: 10, windowSizeInMS: 1000 },
178-
forceProtectionOff: true,
179-
},
180-
]),
181-
{
182-
endpoint: {
183-
method: "POST",
184-
route: "/posts/3",
185-
rateLimiting: { enabled: true, maxRequests: 10, windowSizeInMS: 1000 },
186-
forceProtectionOff: true,
187-
},
188-
route: "/posts/3",
189-
}
190-
);
191-
});
192-
193180
t.test("it matches wildcard route with specific method", async () => {
194181
t.same(
195182
matchEndpoint(
@@ -229,7 +216,7 @@ t.test("it prefers specific route over wildcard", async () => {
229216
matchEndpoint(
230217
{
231218
...context,
232-
route: undefined,
219+
route: "/api/coach",
233220
method: "POST",
234221
url: "http://localhost:4000/api/coach",
235222
},

library/helpers/matchEndpoint.ts

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Endpoint } from "../agent/Config";
22
import { Context } from "../agent/Context";
3-
import { tryParseURL } from "./tryParseURL";
3+
import { tryParseURLPath } from "./tryParseURLPath";
44

55
export type LimitedContext = Pick<Context, "url" | "method" | "route">;
66

@@ -17,32 +17,26 @@ export function matchEndpoint(context: LimitedContext, endpoints: Endpoint[]) {
1717
return endpoint.method === context.method;
1818
});
1919

20-
if (context.route) {
21-
const endpoint = possible.find(
22-
(endpoint) => endpoint.route === context.route
23-
);
20+
const endpoint = possible.find(
21+
(endpoint) => endpoint.route === context.route
22+
);
2423

25-
if (endpoint) {
26-
return { endpoint: endpoint, route: context.route };
27-
}
24+
if (endpoint) {
25+
return { endpoint: endpoint, route: endpoint.route };
2826
}
2927

3028
if (!context.url) {
3129
return undefined;
3230
}
3331

34-
const url = tryParseURL(context.url);
32+
// req.url is relative, so we need to prepend a host to make it absolute
33+
// We just match the pathname, we don't use the host for matching
34+
const path = tryParseURLPath(context.url);
3535

36-
if (!url || !url.pathname) {
36+
if (!path) {
3737
return undefined;
3838
}
3939

40-
const endpoint = possible.find((endpoint) => endpoint.route === url.pathname);
41-
42-
if (endpoint) {
43-
return { endpoint: endpoint, route: endpoint.route };
44-
}
45-
4640
const wildcards = possible
4741
.filter((endpoint) => endpoint.route.includes("*"))
4842
.sort((a, b) => {
@@ -56,7 +50,7 @@ export function matchEndpoint(context: LimitedContext, endpoints: Endpoint[]) {
5650
"i"
5751
);
5852

59-
if (regex.test(url.pathname)) {
53+
if (regex.test(path)) {
6054
return { endpoint: wildcard, route: wildcard.route };
6155
}
6256
}

0 commit comments

Comments
 (0)