diff --git a/.env.example b/.env.example index f5e3749c..d6f44897 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,4 @@ LOG_LEVEL=info # Security JWT_SECRET= +RATE_LIMIT_MAX= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66408af8..8e040d3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,4 +67,5 @@ jobs: MYSQL_USER: test_user MYSQL_PASSWORD: test_password # JWT_SECRET is dynamically generated and loaded from the environment + RATE_LIMIT_MAX: 4 run: npm run db:migrate && npm run test diff --git a/package.json b/package.json index bcabb572..11c9b5e2 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@fastify/helmet": "^11.1.1", "@fastify/jwt": "^8.0.1", "@fastify/mysql": "^4.3.0", + "@fastify/rate-limit": "^9.1.0", "@fastify/sensible": "^5.0.0", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^3.0.0", diff --git a/src/app.ts b/src/app.ts index 4e9b57ca..8cf458a0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,7 +12,7 @@ export default async function serviceApp( ) { // This loads all external plugins defined in plugins/external // those should be registered first as your custom plugins might depend on them - fastify.register(fastifyAutoload, { + await fastify.register(fastifyAutoload, { dir: path.join(import.meta.dirname, "plugins/external"), options: { ...opts } }); @@ -58,7 +58,16 @@ export default async function serviceApp( return { message }; }); - fastify.setNotFoundHandler((request, reply) => { + // An attacker could search for valid URLs if your 404 error handling is not rate limited. + fastify.setNotFoundHandler( + { + preHandler: fastify.rateLimit({ + max: 3, + timeWindow: 500 + }) + }, + (request, reply) => { + request.log.warn( { request: { diff --git a/src/plugins/custom/repository.ts b/src/plugins/custom/repository.ts index 707a7c49..da3cd518 100644 --- a/src/plugins/custom/repository.ts +++ b/src/plugins/custom/repository.ts @@ -47,7 +47,7 @@ function createRepository(fastify: FastifyInstance) { return rows[0] as T; }, - findMany: async (table: string, opts: QueryOptions): Promise => { + findMany: async (table: string, opts: QueryOptions = {}): Promise => { const { select = '*', where = {1:1} } = opts; const [clause, values] = processAssignmentRecord(where, 'AND'); diff --git a/src/plugins/external/1-env.ts b/src/plugins/external/1-env.ts index c14221de..94613ba3 100644 --- a/src/plugins/external/1-env.ts +++ b/src/plugins/external/1-env.ts @@ -10,6 +10,7 @@ declare module "fastify" { MYSQL_PASSWORD: string; MYSQL_DATABASE: string; JWT_SECRET: string; + RATE_LIMIT_MAX: number; }; } } @@ -47,6 +48,10 @@ const schema = { // Security JWT_SECRET: { type: "string" + }, + RATE_LIMIT_MAX: { + type: "number", + default: 100 } } }; diff --git a/src/plugins/external/rate-limit.ts b/src/plugins/external/rate-limit.ts new file mode 100644 index 00000000..d366bd95 --- /dev/null +++ b/src/plugins/external/rate-limit.ts @@ -0,0 +1,16 @@ +import fastifyRateLimit from "@fastify/rate-limit"; +import { FastifyInstance } from "fastify"; + +export const autoConfig = (fastify: FastifyInstance) => { + return { + max: fastify.config.RATE_LIMIT_MAX, + timeWindow: "1 minute" + } +} + +/** + * This plugins is low overhead rate limiter for your routes. + * + * @see {@link https://github.com/fastify/fastify-helmet} + */ +export default fastifyRateLimit diff --git a/test/app/not-found-handler.test.ts b/test/app/not-found-handler.test.ts index 3e5d3adc..4abfa12b 100644 --- a/test/app/not-found-handler.test.ts +++ b/test/app/not-found-handler.test.ts @@ -13,3 +13,23 @@ it("should call notFoundHandler", async (t) => { assert.strictEqual(res.statusCode, 404); assert.deepStrictEqual(JSON.parse(res.payload), { message: "Not Found" }); }); + +it("should be rate limited", async (t) => { + const app = await build(t); + + for (let i = 0; i < 3; i++) { + const res = await app.inject({ + method: "GET", + url: "/this-route-does-not-exist" + }); + + assert.strictEqual(res.statusCode, 404); + } + + const res = await app.inject({ + method: "GET", + url: "/this-route-does-not-exist" + }); + + assert.strictEqual(res.statusCode, 429); +}); diff --git a/test/app/rate-limit.test.ts b/test/app/rate-limit.test.ts new file mode 100644 index 00000000..998fcd67 --- /dev/null +++ b/test/app/rate-limit.test.ts @@ -0,0 +1,23 @@ +import { it } from "node:test"; +import { build } from "../helper.js"; +import assert from "node:assert"; + +it("should be rate limited", async (t) => { + const app = await build(t); + + for (let i = 0; i < 4; i++) { + const res = await app.inject({ + method: "GET", + url: "/" + }); + + assert.strictEqual(res.statusCode, 200); + } + + const res = await app.inject({ + method: "GET", + url: "/" + }); + + assert.strictEqual(res.statusCode, 429); +}); diff --git a/test/routes/home.test.ts b/test/routes/home.test.ts index 6132997c..6069230e 100644 --- a/test/routes/home.test.ts +++ b/test/routes/home.test.ts @@ -4,7 +4,6 @@ import { build } from "../helper.js"; test("GET /", async (t) => { const app = await build(t); - const res = await app.inject({ url: "/" });