From 77e7ad8c9ba293a425e3ae883b1079b26e661efd Mon Sep 17 00:00:00 2001 From: Ofek Itscovits Date: Tue, 18 Mar 2025 10:54:27 +0200 Subject: [PATCH] feat: replace express-rate-limit package with a custom rate limit middleware --- apps/backend/package.json | 1 - apps/backend/src/config/index.ts | 7 +++- apps/backend/src/middlewares/rate-limit.ts | 38 ++++++++++++++++++++++ apps/backend/src/server.ts | 10 +++--- package-lock.json | 16 --------- 5 files changed, 49 insertions(+), 23 deletions(-) create mode 100644 apps/backend/src/middlewares/rate-limit.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 96ddffc..b8e3acd 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -30,7 +30,6 @@ "cors": "^2.8.5", "drizzle-orm": "^0.40.0", "express": "^5.0.1", - "express-rate-limit": "^7.5.0", "http-status-codes": "^2.3.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", diff --git a/apps/backend/src/config/index.ts b/apps/backend/src/config/index.ts index 2d8beb0..5f2a7bd 100644 --- a/apps/backend/src/config/index.ts +++ b/apps/backend/src/config/index.ts @@ -1,5 +1,10 @@ import { z, ZodError } from "zod"; +const timeSpanType = z + .string() + .regex(/^\d+[smhd]$/, "Invalid format. Use formats like '15m', '1h', '7d'.") + .transform((val) => val as `${number}${"s" | "m" | "h" | "d"}`); + const environmentVariableSchema = z.object({ // Node Environment NODE_ENV: z.enum(["development", "test", "production"]), @@ -70,7 +75,7 @@ const environmentVariableSchema = z.object({ TWILIO_PHONE_NUMBER: z.string(), // Rate limit - WINDOW_SIZE_IN_MINUTES: z.coerce.number().positive().int().default(15), + WINDOW_SIZE_IN_MINUTES: timeSpanType.default("15m"), MAX_NUMBER_OF_REQUESTS_PER_WINDOW_SIZE: z.coerce .number() .positive() diff --git a/apps/backend/src/middlewares/rate-limit.ts b/apps/backend/src/middlewares/rate-limit.ts new file mode 100644 index 0000000..5e48216 --- /dev/null +++ b/apps/backend/src/middlewares/rate-limit.ts @@ -0,0 +1,38 @@ +import { Request, Response, NextFunction } from "express"; +import redis from "@repo/backend/redis"; +import { parseTimeSpan, type TimeSpan } from "@repo/backend/utils/time"; +import { RateLimitError } from "@repo/backend/utils/errors"; + +const rateLimitHandler = ({ + endpoint = "global", + timeSpan, + limit, + message, +}: { + timeSpan: TimeSpan; + limit: number; + endpoint?: string; + message?: string; +}) => { + return async (request: Request, response: Response, next: NextFunction) => { + const { ip } = request; + const redisId = `rate-limit:${endpoint}/${ip}`; + + const requests = await redis.incr(redisId); + if (requests === 1) await redis.expire(redisId, parseTimeSpan(timeSpan)); + + limit = process.env.NODE_ENV === "development" ? Infinity : limit; + if (requests > limit) { + next( + new RateLimitError( + message ?? "Too many requests, please try again later", + ), + ); + return; + } + + next(); + }; +}; + +export default rateLimitHandler; diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index ce342ab..7254f44 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -1,11 +1,11 @@ import express, { type Express } from "express"; import morgan from "morgan"; import cors from "cors"; -import rateLimit from "express-rate-limit"; import cookieParser from "cookie-parser"; import { NotFoundError } from "@repo/backend/utils/errors"; import rootRouter from "@repo/backend/modules"; import errorHandler from "@repo/backend/middlewares/error"; +import rateLimitHandler from "@repo/backend/middlewares/rate-limit"; export const createServer = (): Express => { const app = express(); @@ -17,10 +17,10 @@ export const createServer = (): Express => { .use(cookieParser()) .use(cors()) .use( - rateLimit({ - windowMs: process.env.WINDOW_SIZE_IN_MINUTES * 60 * 1000, - max: process.env.MAX_NUMBER_OF_REQUESTS_PER_WINDOW_SIZE, - }), + rateLimitHandler({ + timeSpan: process.env.WINDOW_SIZE_IN_MINUTES, + limit: process.env.MAX_NUMBER_OF_REQUESTS_PER_WINDOW_SIZE, + }) ) .get("/healthcheck", (_req, res) => { res.json({ diff --git a/package-lock.json b/package-lock.json index 19252f5..0da754e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,6 @@ "cors": "^2.8.5", "drizzle-orm": "^0.40.0", "express": "^5.0.1", - "express-rate-limit": "^7.5.0", "http-status-codes": "^2.3.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", @@ -6905,21 +6904,6 @@ "node": ">= 18" } }, - "node_modules/express-rate-limit": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", - "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": "^4.11 || 5 || ^5.0.0-beta.1" - } - }, "node_modules/express/node_modules/cookie": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",