252 lines
7.0 KiB
TypeScript
252 lines
7.0 KiB
TypeScript
/**
|
|
* Rate Limiter Middleware
|
|
*
|
|
* Provides centralized rate limiting for API endpoints using a configurable
|
|
* memory store with optional Redis support for horizontal scaling.
|
|
*
|
|
* Usage:
|
|
* import { authLimiter, apiLimiter, uploadLimiter } from './middlewares/rateLimiter.ts';
|
|
* app.use('/api/auth', authLimiter);
|
|
* app.use('/api', apiLimiter);
|
|
*/
|
|
|
|
import type { RequestHandler } from 'express';
|
|
|
|
import type { RateLimitEntry, RateLimiterOptions } from '../types/index.ts';
|
|
import { logger } from '../utils/logger.ts';
|
|
import { getCurrentUser } from '../utils/request-context.ts';
|
|
|
|
// In-memory store for rate limiting
|
|
// For horizontal scaling, replace with Redis store
|
|
const rateLimitStore = new Map<string, RateLimitEntry>();
|
|
|
|
// Cleanup interval for expired entries (every 5 minutes)
|
|
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
|
|
// Periodic cleanup of expired entries
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
let cleaned = 0;
|
|
|
|
for (const [key, entry] of rateLimitStore.entries()) {
|
|
if (entry.expiresAt <= now) {
|
|
rateLimitStore.delete(key);
|
|
cleaned++;
|
|
}
|
|
}
|
|
|
|
if (cleaned > 0) {
|
|
logger.debug({ cleaned }, 'Rate limit store cleanup');
|
|
}
|
|
}, CLEANUP_INTERVAL_MS);
|
|
|
|
/**
|
|
* Create a rate limiter middleware
|
|
*
|
|
* @param {Object} options - Configuration options
|
|
* @param {string} options.keyPrefix - Prefix for rate limit keys (e.g., 'auth', 'api')
|
|
* @param {number} options.windowMs - Time window in milliseconds (default: 15 minutes)
|
|
* @param {number} options.max - Maximum requests per window (default: 100)
|
|
* @param {string} [options.message] - Custom error message
|
|
* @param {boolean} [options.skipFailedRequests] - Don't count failed requests (status >= 400)
|
|
* @param {Function} [options.keyGenerator] - Custom key generator (req) => string
|
|
* @param {Function} [options.skip] - Skip rate limiting for certain requests (req) => boolean
|
|
* @returns {Function} Express middleware
|
|
*/
|
|
const createRateLimiter = (options: RateLimiterOptions = {}): RequestHandler => {
|
|
const {
|
|
keyPrefix = 'rate-limit',
|
|
windowMs = 15 * 60 * 1000, // 15 minutes
|
|
max = 100,
|
|
message = 'Too many requests. Please try again later.',
|
|
skipFailedRequests = false,
|
|
keyGenerator = null,
|
|
skip = null,
|
|
} = options;
|
|
|
|
return (req, res, next) => {
|
|
// Allow skipping rate limiting for certain requests
|
|
if (skip && skip(req)) {
|
|
return next();
|
|
}
|
|
|
|
// Skip in development when accessing from localhost (optional)
|
|
if (
|
|
process.env.NODE_ENV === 'development' &&
|
|
(req.ip === '127.0.0.1' || req.ip === '::1')
|
|
) {
|
|
return next();
|
|
}
|
|
|
|
// Generate rate limit key
|
|
const clientKey = keyGenerator
|
|
? keyGenerator(req)
|
|
: req.ip || req.connection?.remoteAddress || 'unknown';
|
|
const key = `${keyPrefix}:${clientKey}`;
|
|
const now = Date.now();
|
|
|
|
// Get or create rate limit entry
|
|
let entry = rateLimitStore.get(key);
|
|
|
|
if (!entry || entry.expiresAt <= now) {
|
|
// Create new entry
|
|
entry = {
|
|
count: 0,
|
|
expiresAt: now + windowMs,
|
|
resetTime: new Date(now + windowMs).toISOString(),
|
|
};
|
|
}
|
|
|
|
// Add standard rate limit headers
|
|
const remaining = Math.max(0, max - entry.count - 1);
|
|
const retryAfter = Math.ceil((entry.expiresAt - now) / 1000);
|
|
|
|
res.setHeader('X-RateLimit-Limit', max);
|
|
res.setHeader('X-RateLimit-Remaining', remaining);
|
|
res.setHeader('X-RateLimit-Reset', entry.resetTime);
|
|
|
|
// Check if rate limit exceeded
|
|
if (entry.count >= max) {
|
|
res.setHeader('Retry-After', retryAfter);
|
|
|
|
logger.warn(
|
|
{
|
|
ip: clientKey,
|
|
keyPrefix,
|
|
count: entry.count,
|
|
max,
|
|
retryAfter,
|
|
},
|
|
'Rate limit exceeded',
|
|
);
|
|
|
|
return res.status(429).json({
|
|
error: 'Too Many Requests',
|
|
message,
|
|
retryAfter,
|
|
});
|
|
}
|
|
|
|
// Increment count
|
|
entry.count += 1;
|
|
rateLimitStore.set(key, entry);
|
|
|
|
// If skipFailedRequests is enabled, decrement on failed response
|
|
if (skipFailedRequests) {
|
|
const originalSend = res.send.bind(res);
|
|
res.send = function sendWithRateLimitRefund(body?: unknown) {
|
|
if (res.statusCode >= 400) {
|
|
const currentEntry = rateLimitStore.get(key);
|
|
if (currentEntry && currentEntry.count > 0) {
|
|
currentEntry.count -= 1;
|
|
rateLimitStore.set(key, currentEntry);
|
|
}
|
|
}
|
|
return originalSend(body);
|
|
};
|
|
}
|
|
|
|
next();
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Create a rate limiter that uses both IP and user ID as key
|
|
* Useful for authenticated endpoints
|
|
*/
|
|
const createAuthenticatedRateLimiter = (
|
|
options: RateLimiterOptions = {},
|
|
): RequestHandler => {
|
|
return createRateLimiter({
|
|
...options,
|
|
keyGenerator: (req) => {
|
|
const userId = getCurrentUser(req)?.id || 'anonymous';
|
|
const ip = req.ip || 'unknown';
|
|
return `${ip}:${userId}`;
|
|
},
|
|
});
|
|
};
|
|
|
|
// Pre-configured limiters for common use cases
|
|
|
|
/**
|
|
* Auth limiter - Strict limits for authentication endpoints
|
|
* 5 requests per 15 minutes per IP
|
|
*/
|
|
const authLimiter = createRateLimiter({
|
|
keyPrefix: 'auth',
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 10, // Allow 10 attempts per window
|
|
message: 'Too many authentication attempts. Please try again later.',
|
|
skipFailedRequests: false, // Count failed attempts
|
|
});
|
|
|
|
/**
|
|
* Password reset limiter - Prevent password reset abuse
|
|
* 5 requests per hour per IP
|
|
*/
|
|
const passwordResetLimiter = createRateLimiter({
|
|
keyPrefix: 'password-reset',
|
|
windowMs: 60 * 60 * 1000, // 1 hour
|
|
max: 5,
|
|
message: 'Too many password reset requests. Please try again later.',
|
|
});
|
|
|
|
/**
|
|
* General API limiter - Standard limits for API endpoints
|
|
* 100 requests per minute per IP
|
|
*/
|
|
const apiLimiter = createRateLimiter({
|
|
keyPrefix: 'api',
|
|
windowMs: 60 * 1000, // 1 minute
|
|
max: 100,
|
|
message: 'Too many requests. Please slow down.',
|
|
skipFailedRequests: true, // Don't penalize for errors
|
|
});
|
|
|
|
/**
|
|
* Upload limiter - Limits for file uploads
|
|
* 200 uploads per minute per IP (supports batch uploads of 100+ files)
|
|
*/
|
|
const uploadLimiter = createRateLimiter({
|
|
keyPrefix: 'upload',
|
|
windowMs: 60 * 1000, // 1 minute
|
|
max: 200,
|
|
message: 'Too many file uploads. Please wait before uploading more.',
|
|
skipFailedRequests: true, // Don't penalize for failed uploads
|
|
});
|
|
|
|
/**
|
|
* Download limiter - Permissive limits for file downloads
|
|
* 1000 requests per minute per IP (supports offline mode downloading full presentations)
|
|
*/
|
|
const downloadLimiter = createRateLimiter({
|
|
keyPrefix: 'download',
|
|
windowMs: 60 * 1000, // 1 minute
|
|
max: 1000,
|
|
message: 'Too many download requests. Please slow down.',
|
|
skipFailedRequests: true, // Don't penalize for errors
|
|
});
|
|
|
|
/**
|
|
* Search limiter - Prevent search abuse
|
|
* 30 searches per minute per IP
|
|
*/
|
|
const searchLimiter = createRateLimiter({
|
|
keyPrefix: 'search',
|
|
windowMs: 60 * 1000, // 1 minute
|
|
max: 30,
|
|
message: 'Too many search requests. Please slow down.',
|
|
});
|
|
|
|
export {
|
|
createRateLimiter,
|
|
createAuthenticatedRateLimiter,
|
|
authLimiter,
|
|
passwordResetLimiter,
|
|
apiLimiter,
|
|
uploadLimiter,
|
|
downloadLimiter,
|
|
searchLimiter,
|
|
};
|