Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions lib/middlewares/rateLimiter.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Reads named profiles from config.rateLimit and exports a limiter for each.
* When a profile is missing (e.g. in dev), returns a passthrough middleware.
*/
import rateLimit from 'express-rate-limit';
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';

import config from '../../config/index.js';

Expand All @@ -16,6 +16,14 @@ import config from '../../config/index.js';
*/
const passthrough = (req, res, next) => next();

/**
* @desc Default rate-limit key generator. Uses the authenticated user id when
* available, otherwise falls back to an IPv6-safe IP key via ipKeyGenerator.
* @param {import('express').Request} req - Express request object
* @returns {string} User id when available, otherwise IPv6-safe IP key
*/
const defaultKeyGenerator = (req) => req.user?._id?.toString() || ipKeyGenerator(req.ip);

/**
* @desc Proxy that lazily creates rate-limit middleware for each named profile.
* Returns the configured limiter or a passthrough when the profile is missing.
Expand All @@ -28,7 +36,7 @@ const limiters = new Proxy({}, {
if (name in target) return target[name];
const opts = config.rateLimit?.[name];
if (opts) {
const keyGenerator = opts.keyGenerator ?? ((req) => req.user?._id?.toString() || req.ip);
const keyGenerator = opts.keyGenerator ?? defaultKeyGenerator;
target[name] = rateLimit({ ...opts, keyGenerator });
return target[name];
}
Expand Down
9 changes: 5 additions & 4 deletions lib/middlewares/tests/rateLimiter.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jest.unstable_mockModule('express-rate-limit', () => ({
middleware._keyGenerator = opts.keyGenerator;
return middleware;
},
ipKeyGenerator: (ip) => `ipkg:${ip}`,
}));

// Import once — the Proxy is a singleton, so use distinct profile names per test
Expand Down Expand Up @@ -60,26 +61,26 @@ describe('rateLimiter middleware unit tests:', () => {
expect(opts.keyGenerator(req)).toBe('507f1f77bcf86cd799439011');
});

test('should fall back to req.ip for unauthenticated requests', () => {
test('should fall back to ipKeyGenerator(req.ip) for unauthenticated requests', () => {
mockRateLimitConfig = {
profileUnauth: { windowMs: 1000, max: 100 },
};
limiters.profileUnauth;

const opts = calls[calls.length - 1];
const req = { ip: '203.0.113.42' };
expect(opts.keyGenerator(req)).toBe('203.0.113.42');
expect(opts.keyGenerator(req)).toBe('ipkg:203.0.113.42');
});

test('should fall back to req.ip when user exists but _id is undefined', () => {
test('should fall back to ipKeyGenerator(req.ip) when user exists but _id is undefined', () => {
mockRateLimitConfig = {
profileNoId: { windowMs: 1000, max: 5 },
};
limiters.profileNoId;

const opts = calls[calls.length - 1];
const req = { user: {}, ip: '192.168.1.1' };
expect(opts.keyGenerator(req)).toBe('192.168.1.1');
expect(opts.keyGenerator(req)).toBe('ipkg:192.168.1.1');
});

test('should respect custom keyGenerator from config profile', () => {
Expand Down
Loading