39948-vm/backend/src/middlewares/rateLimiter.ts
2026-07-01 15:45:38 +02:00

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,
};