Skip to content

Commit 66c2aa5

Browse files
Rate limit (#7)
* fix: reset soft to origin/main * docs: add RATE_LIMIT_MAX to .env.example
1 parent 3d98ff6 commit 66c2aa5

File tree

10 files changed

+79
-4
lines changed

10 files changed

+79
-4
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ LOG_LEVEL=info
1515

1616
# Security
1717
JWT_SECRET=
18+
RATE_LIMIT_MAX=

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,5 @@ jobs:
6767
MYSQL_USER: test_user
6868
MYSQL_PASSWORD: test_password
6969
# JWT_SECRET is dynamically generated and loaded from the environment
70+
RATE_LIMIT_MAX: 4
7071
run: npm run db:migrate && npm run test

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@fastify/helmet": "^11.1.1",
3030
"@fastify/jwt": "^8.0.1",
3131
"@fastify/mysql": "^4.3.0",
32+
"@fastify/rate-limit": "^9.1.0",
3233
"@fastify/sensible": "^5.0.0",
3334
"@fastify/swagger": "^8.14.0",
3435
"@fastify/swagger-ui": "^3.0.0",

src/app.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export default async function serviceApp(
1212
) {
1313
// This loads all external plugins defined in plugins/external
1414
// those should be registered first as your custom plugins might depend on them
15-
fastify.register(fastifyAutoload, {
15+
await fastify.register(fastifyAutoload, {
1616
dir: path.join(import.meta.dirname, "plugins/external"),
1717
options: { ...opts }
1818
});
@@ -58,7 +58,16 @@ export default async function serviceApp(
5858
return { message };
5959
});
6060

61-
fastify.setNotFoundHandler((request, reply) => {
61+
// An attacker could search for valid URLs if your 404 error handling is not rate limited.
62+
fastify.setNotFoundHandler(
63+
{
64+
preHandler: fastify.rateLimit({
65+
max: 3,
66+
timeWindow: 500
67+
})
68+
},
69+
(request, reply) => {
70+
6271
request.log.warn(
6372
{
6473
request: {

src/plugins/custom/repository.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ function createRepository(fastify: FastifyInstance) {
4747
return rows[0] as T;
4848
},
4949

50-
findMany: async <T>(table: string, opts: QueryOptions): Promise<T[]> => {
50+
findMany: async <T>(table: string, opts: QueryOptions = {}): Promise<T[]> => {
5151
const { select = '*', where = {1:1} } = opts;
5252
const [clause, values] = processAssignmentRecord(where, 'AND');
5353

src/plugins/external/1-env.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ declare module "fastify" {
1010
MYSQL_PASSWORD: string;
1111
MYSQL_DATABASE: string;
1212
JWT_SECRET: string;
13+
RATE_LIMIT_MAX: number;
1314
};
1415
}
1516
}
@@ -47,6 +48,10 @@ const schema = {
4748
// Security
4849
JWT_SECRET: {
4950
type: "string"
51+
},
52+
RATE_LIMIT_MAX: {
53+
type: "number",
54+
default: 100
5055
}
5156
}
5257
};

src/plugins/external/rate-limit.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import fastifyRateLimit from "@fastify/rate-limit";
2+
import { FastifyInstance } from "fastify";
3+
4+
export const autoConfig = (fastify: FastifyInstance) => {
5+
return {
6+
max: fastify.config.RATE_LIMIT_MAX,
7+
timeWindow: "1 minute"
8+
}
9+
}
10+
11+
/**
12+
* This plugins is low overhead rate limiter for your routes.
13+
*
14+
* @see {@link https://github.com/fastify/fastify-helmet}
15+
*/
16+
export default fastifyRateLimit

test/app/not-found-handler.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,23 @@ it("should call notFoundHandler", async (t) => {
1313
assert.strictEqual(res.statusCode, 404);
1414
assert.deepStrictEqual(JSON.parse(res.payload), { message: "Not Found" });
1515
});
16+
17+
it("should be rate limited", async (t) => {
18+
const app = await build(t);
19+
20+
for (let i = 0; i < 3; i++) {
21+
const res = await app.inject({
22+
method: "GET",
23+
url: "/this-route-does-not-exist"
24+
});
25+
26+
assert.strictEqual(res.statusCode, 404);
27+
}
28+
29+
const res = await app.inject({
30+
method: "GET",
31+
url: "/this-route-does-not-exist"
32+
});
33+
34+
assert.strictEqual(res.statusCode, 429);
35+
});

test/app/rate-limit.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { it } from "node:test";
2+
import { build } from "../helper.js";
3+
import assert from "node:assert";
4+
5+
it("should be rate limited", async (t) => {
6+
const app = await build(t);
7+
8+
for (let i = 0; i < 4; i++) {
9+
const res = await app.inject({
10+
method: "GET",
11+
url: "/"
12+
});
13+
14+
assert.strictEqual(res.statusCode, 200);
15+
}
16+
17+
const res = await app.inject({
18+
method: "GET",
19+
url: "/"
20+
});
21+
22+
assert.strictEqual(res.statusCode, 429);
23+
});

test/routes/home.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { build } from "../helper.js";
44

55
test("GET /", async (t) => {
66
const app = await build(t);
7-
87
const res = await app.inject({
98
url: "/"
109
});

0 commit comments

Comments
 (0)