From 4a797769866ddb7e708a1691236fa98d666ea776 Mon Sep 17 00:00:00 2001 From: ummarig Date: Thu, 5 Mar 2026 11:34:22 +0000 Subject: [PATCH 1/3] implemented the rate limit --- package-lock.json | 33 ++++++++++++++++++++++++++++++ package.json | 1 + src/app.js | 4 ++++ src/middlewares/rateLimiter.js | 37 ++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 src/middlewares/rateLimiter.js diff --git a/package-lock.json b/package-lock.json index af646d6..14cd9bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", + "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", "joi": "^18.0.2", "jsonwebtoken": "^9.0.3", @@ -63,6 +64,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1971,6 +1973,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2311,6 +2314,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3030,6 +3034,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3433,6 +3438,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3471,6 +3477,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4092,6 +4116,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index 2bcb386..4b2ff17 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", + "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", "joi": "^18.0.2", "jsonwebtoken": "^9.0.3", diff --git a/src/app.js b/src/app.js index 79b843c..1ac2b69 100644 --- a/src/app.js +++ b/src/app.js @@ -4,6 +4,7 @@ const helmet = require('helmet'); const morgan = require('morgan'); const { sendSuccess } = require('./utils/response'); const { getLoggerStream } = require('./utils/logger'); +const { globalLimiter, authLimiter } = require('./middlewares/rateLimiter'); const authRoutes = require('./routes/auth.routes'); const protectedRoutes = require('./routes/protected.routes'); @@ -22,6 +23,9 @@ app.use( ); app.use(helmet()); +// rate limiting middleware +app.use(globalLimiter); + // Determine logging format based on environment const morganFormat = process.env.NODE_ENV === 'production' ? 'combined' : 'dev'; app.use(morgan(morganFormat, { stream: getLoggerStream() })); diff --git a/src/middlewares/rateLimiter.js b/src/middlewares/rateLimiter.js new file mode 100644 index 0000000..37aea2f --- /dev/null +++ b/src/middlewares/rateLimiter.js @@ -0,0 +1,37 @@ +const rateLimit = require('express-rate-limit'); + +// shared window for both global and auth limiters (milliseconds) +const WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000; + +function createLimiter({ max }) { + return rateLimit({ + windowMs: WINDOW_MS, + max, + standardHeaders: true, // Return rate limit info in the RateLimit-* headers + legacyHeaders: false, // Disable the X-RateLimit-* headers + handler: (req, res) => { + // express-rate-limit will automatically set Retry-After header if + // standardHeaders is enabled, but we send it explicitly for clarity. + const retryAfter = Math.ceil(WINDOW_MS / 1000); + res.set('Retry-After', String(retryAfter)); + + // Using the shared response format so tests and clients are consistent + res.status(429).json({ + success: false, + statusCode: 429, + message: 'Too many requests, please try again later.', + data: {}, + }); + }, + }); +} + +const globalLimiter = createLimiter({ + max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100, +}); + +const authLimiter = createLimiter({ + max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 10, +}); + +module.exports = { globalLimiter, authLimiter }; From 081f27318c5288878d3756300bb06965a589cb2d Mon Sep 17 00:00:00 2001 From: ummarig Date: Thu, 5 Mar 2026 11:34:27 +0000 Subject: [PATCH 2/3] implemented the rate limit --- src/__tests__/rateLimiter.test.js | 52 +++++++++++++++++++++++++++++++ src/app.js | 4 +-- 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/rateLimiter.test.js diff --git a/src/__tests__/rateLimiter.test.js b/src/__tests__/rateLimiter.test.js new file mode 100644 index 0000000..af5bffb --- /dev/null +++ b/src/__tests__/rateLimiter.test.js @@ -0,0 +1,52 @@ +const request = require('supertest'); + +// helper to load app with fresh environment variables +function loadAppWithEnv(env = {}) { + // clear cache so that modules pick up new env values + jest.resetModules(); + Object.assign(process.env, env); + return require('../app'); +} + +describe('Rate limiting middleware', () => { + beforeEach(() => { + // make sure we start from a clean state + jest.resetModules(); + }); + + it('enforces the global limit and returns Retry-After header', async () => { + const app = loadAppWithEnv({ + RATE_LIMIT_WINDOW_MS: '1000', + RATE_LIMIT_MAX: '2', + }); + + // first two requests should succeed + await request(app).get('/api/health').expect(200); + await request(app).get('/api/health').expect(200); + + // third request should be blocked + const res = await request(app).get('/api/health').expect(429); + expect(res.headers['retry-after']).toBeDefined(); + expect(res.body).toMatchObject({ + success: false, + statusCode: 429, + message: expect.stringContaining('Too many requests'), + }); + }); + + it('enforces a stricter limit on auth routes', async () => { + const app = loadAppWithEnv({ + RATE_LIMIT_WINDOW_MS: '1000', + AUTH_RATE_LIMIT_MAX: '1', + }); + + // first auth request should go through (payload may be invalid, but not a rate-limit error) + await request(app).post('/api/auth/login').send({}).expect(res => { + expect([200, 400, 401, 422]).toContain(res.status); + }); + + // second request should hit the limit + const res = await request(app).post('/api/auth/login').send({}).expect(429); + expect(res.headers['retry-after']).toBeDefined(); + }); +}); diff --git a/src/app.js b/src/app.js index 1ac2b69..4dabaa6 100644 --- a/src/app.js +++ b/src/app.js @@ -34,8 +34,8 @@ app.get('/api/health', (req, res) => { sendSuccess(res, { status: 'ok' }, 200, 'Server is healthy'); }); -// Auth routes -app.use('/api/auth', authRoutes); +// Auth routes (stricter rate limit) +app.use('/api/auth', authLimiter, authRoutes); // Protected routes app.use('/api/protected', protectedRoutes); From 739ff51bd37072433fe2e50d021e818668f912d7 Mon Sep 17 00:00:00 2001 From: ummarig Date: Thu, 5 Mar 2026 11:36:09 +0000 Subject: [PATCH 3/3] implemented the docs --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index f7c0c66..6a48d30 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,20 @@ Swagger is controlled via environment variables: - **Production**: Swagger is **disabled** by default (set `ENABLE_SWAGGER=true` to override) - **Explicit override**: Set `ENABLE_SWAGGER=true` or `ENABLE_SWAGGER=false` to force enable/disable regardless of environment +## Rate limiting configuration + +The application ships with a built‑in rate limiter based on [`express-rate-limit`](https://www.npmjs.com/package/express-rate-limit). It protects the entire API with a permissive window, and adds a more restrictive policy to +all `/api/auth` endpoints. + +| Variable | Description | Default | +|----------------------|-------------------------------------------------------------|-----------------| +| `RATE_LIMIT_WINDOW_MS` | Time window in milliseconds | `900000` (15m) | +| `RATE_LIMIT_MAX` | Max requests per window for general routes | `100` | +| `AUTH_RATE_LIMIT_MAX` | Max requests per window for auth routes | `10` | + +When a client exceeds the limit the server replies with `429 Too Many Requests` and a `Retry-After` header indicating how many seconds remain +in the current window. + ## Example `.env` Configuration ```env @@ -103,6 +117,11 @@ ENABLE_SWAGGER=true # Or disable in development NODE_ENV=development ENABLE_SWAGGER=false + +# Rate limiting (optional overrides) +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=100 +AUTH_RATE_LIMIT_MAX=10 ``` # 📌 How to Contribute