/** * Memento MCP Server - Rate Limiting * * Implements token bucket and sliding window rate limiting. * Per-user and global limits supported. */ import config from './config.js'; import { recordRateLimitBlocked } from './metrics.js'; /** * Rate limit entry for tracking usage */ class RateLimitEntry { constructor(windowMs, maxRequests) { this.resetTime = Date.now() + windowMs; this.maxRequests = maxRequests; this.requests = 0; this.windowMs = windowMs; } increment() { // Check if window has expired if (Date.now() >= this.resetTime) { this.reset(); } this.requests++; return this.requests <= this.maxRequests; } reset() { this.requests = 0; this.resetTime = Date.now() + this.windowMs; } get remaining() { if (Date.now() >= this.resetTime) { return this.maxRequests; } return Math.max(0, this.maxRequests - this.requests); } get retryAfter() { if (Date.now() >= this.resetTime) { return 0; } return Math.ceil((this.resetTime - Date.now()) / 1000); } } /** * In-memory rate limit storage */ class RateLimitStore { constructor() { this.limits = new Map(); this.cleanupInterval = null; } startCleanup(intervalMs = 60000) { this.cleanupInterval = setInterval(() => this.cleanup(), intervalMs); } stopCleanup() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } } cleanup() { const now = Date.now(); let cleaned = 0; for (const [key, entry] of this.limits.entries()) { if (now >= entry.resetTime) { this.limits.delete(key); cleaned++; } } if (cleaned > 0 && config.logLevel === 'debug') { console.log(`[RATE_LIMIT] Cleaned ${cleaned} expired entries`); } } get(key) { return this.limits.get(key); } set(key, entry) { this.limits.set(key, entry); } delete(key) { this.limits.delete(key); } get size() { return this.limits.size; } clear() { this.limits.clear(); } getStats() { return { totalEntries: this.limits.size, activeEntries: [...this.limits.values()].filter((e) => e.requests > 0).length, }; } } /** * Global rate limit store */ const store = new RateLimitStore(); store.startCleanup(); /** * Rate limiter class */ export class RateLimiter { constructor(options = {}) { this.windowMs = options.windowMs || config.rateLimitWindow; this.maxRequests = options.maxRequests || config.rateLimit; this.keyGenerator = options.keyGenerator || ((req) => req.userSession?.id || 'anonymous'); this.skipSuccessfulRequests = options.skipSuccessfulRequests || false; this.skipFailedRequests = options.skipFailedRequests || false; } /** * Check if a request should be rate limited * * @param {object} req - Request object * @returns {{ allowed: boolean, limit: number, remaining: number, resetTime: number, retryAfter?: number }} */ check(req) { const key = this.keyGenerator(req); let entry = store.get(key); if (!entry) { entry = new RateLimitEntry(this.windowMs, this.maxRequests); store.set(key, entry); } const allowed = entry.increment(); return { allowed, limit: this.maxRequests, remaining: entry.remaining, resetTime: entry.resetTime, retryAfter: allowed ? undefined : entry.retryAfter, }; } /** * Reset rate limit for a specific key * * @param {string} key - Rate limit key */ reset(key) { store.delete(key); } /** * Get rate limit info for a specific key * * @param {string} key - Rate limit key * @returns {object|null} */ getInfo(key) { const entry = store.get(key); if (!entry) return null; return { limit: this.maxRequests, remaining: entry.remaining, resetTime: entry.resetTime, retryAfter: entry.retryAfter, }; } /** * Get store statistics */ getStats() { return store.getStats(); } } /** * Predefined rate limiters */ // Global rate limiter (applies to all requests) export const globalRateLimiter = new RateLimiter({ windowMs: config.rateLimitWindow, maxRequests: config.rateLimit, keyGenerator: () => 'global', }); // Per-user rate limiter export const userRateLimiter = new RateLimiter({ windowMs: config.rateLimitWindow, maxRequests: config.rateLimit, keyGenerator: (req) => `user:${req.userSession?.id || 'anonymous'}`, }); // Per-API-key rate limiter export const apiKeyRateLimiter = new RateLimiter({ windowMs: config.rateLimitWindow, maxRequests: config.rateLimit, keyGenerator: (req) => `apikey:${req.headers['x-api-key'] || 'none'}`, }); // Per-tool rate limiter (more restrictive for expensive operations) export const toolRateLimiter = new RateLimiter({ windowMs: config.rateLimitWindow, maxRequests: Math.max(10, Math.floor(config.rateLimit / 2)), keyGenerator: (req) => { const userId = req.userSession?.id || 'anonymous'; // Extract tool name from request body const tool = req.body?.method || req.body?.tool || 'unknown'; return `tool:${userId}:${tool}`; }, }); /** * Express middleware for rate limiting * * @param {RateLimiter} limiter - Rate limiter instance * @param {object} options - Middleware options */ export function rateLimitMiddleware(limiter, options = {}) { const { skipSuccessfulRequests = false, skipFailedRequests = false, onLimitReached = null, handler = null, } = options; return (req, res, next) => { // Skip if rate limiting is disabled if (config.rateLimit <= 0) { return next(); } const result = limiter.check(req); // Add rate limit headers to response res.setHeader('X-RateLimit-Limit', result.limit); res.setHeader('X-RateLimit-Remaining', result.remaining); res.setHeader('X-RateLimit-Reset', new Date(result.resetTime).toISOString()); if (!result.allowed) { // Rate limit exceeded recordRateLimitBlocked(limiter.keyGenerator(req)); const identifier = limiter.keyGenerator(req); if (handler) { return handler(req, res, result); } res.setHeader('Retry-After', result.retryAfter.toString()); return res.status(429).json({ error: { code: 429, message: 'Rate limit exceeded', detail: `Too many requests. Retry after ${result.retryAfter} seconds`, retryAfter: result.retryAfter, resetTime: new Date(result.resetTime).toISOString(), }, }); } // Attach rate limit info to request for later use req.rateLimit = result; next(); }; } /** * Combined rate limiting middleware * Applies all rate limiters (global, per-user, per-tool) */ export function combinedRateLimitMiddleware(req, res, next) { if (config.rateLimit <= 0) { return next(); } // Check all limiters const limiters = [globalRateLimiter, userRateLimiter, apiKeyRateLimiter]; // For tool calls, also check tool-specific limiter if (req.body?.method || req.body?.tool) { limiters.push(toolRateLimiter); } for (const limiter of limiters) { const result = limiter.check(req); res.setHeader('X-RateLimit-Limit', result.limit); res.setHeader('X-RateLimit-Remaining', result.remaining); res.setHeader('X-RateLimit-Reset', new Date(result.resetTime).toISOString()); if (!result.allowed) { recordRateLimitBlocked(limiter.keyGenerator(req)); res.setHeader('Retry-After', result.retryAfter.toString()); return res.status(429).json({ error: { code: 429, message: 'Rate limit exceeded', detail: `Too many requests. Retry after ${result.retryAfter} seconds`, retryAfter: result.retryAfter, resetTime: new Date(result.resetTime).toISOString(), }, }); } } next(); } /** * Reset rate limits for a user/session * * @param {string} identifier - User ID or API key */ export function resetRateLimit(identifier) { store.delete(`user:${identifier}`); store.delete(`apikey:${identifier}`); } /** * Get all rate limit stats */ export function getRateLimitStats() { return { store: store.getStats(), config: { windowMs: config.rateLimitWindow, maxRequests: config.rateLimit, }, }; } /** * Clear all rate limits (useful for testing) */ export function clearAllRateLimits() { store.clear(); } /** * Stop rate limiter cleanup interval */ export function shutdown() { store.stopCleanup(); } // Auto-start cleanup on module load if (typeof process !== 'undefined') { process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); } export default { RateLimiter, globalRateLimiter, userRateLimiter, apiKeyRateLimiter, toolRateLimiter, rateLimitMiddleware, combinedRateLimitMiddleware, resetRateLimit, getRateLimitStats, clearAllRateLimits, shutdown, };