77 lines
2.5 KiB
JavaScript
77 lines
2.5 KiB
JavaScript
/**
|
|
* IP-keyed rate-limit middleware.
|
|
*
|
|
* Sliding-window counter per remote IP, kept in-memory. Use for routes
|
|
* that take expensive upstream calls and aren't behind requireAuth —
|
|
* the public demo (/api/analyze/*) is the canonical caller. Auth'd
|
|
* routes are gated by tier, which already throttles abuse.
|
|
*
|
|
* Why in-memory not Redis: this middleware is the FIRST line of defense
|
|
* — it must not depend on Redis being warm. If Redis is down the API
|
|
* still serves and this still throttles. Memory cost is bounded by
|
|
* MAX_TRACKED_IPS (the LRU-style trim on overflow).
|
|
*
|
|
* Why not the factory in src/utils/rateLimiter.js: that gives one bucket
|
|
* per call site. We need one bucket PER IP.
|
|
*/
|
|
|
|
const DEFAULT_WINDOW_MS = 60_000;
|
|
const DEFAULT_MAX = 10;
|
|
const MAX_TRACKED_IPS = 10_000;
|
|
|
|
function clientIp(req) {
|
|
// Express's req.ip respects trust proxy; fall back to socket if not.
|
|
return req.ip || req.socket?.remoteAddress || 'unknown';
|
|
}
|
|
|
|
function createRateLimit({ windowMs = DEFAULT_WINDOW_MS, max = DEFAULT_MAX, key = clientIp } = {}) {
|
|
// Map preserves insertion order; oldest-IP eviction is O(1) via shift.
|
|
const hits = new Map();
|
|
|
|
function evictIfFull() {
|
|
if (hits.size <= MAX_TRACKED_IPS) return;
|
|
// Drop the oldest entry — Map.keys() yields in insertion order.
|
|
const first = hits.keys().next().value;
|
|
if (first !== undefined) hits.delete(first);
|
|
}
|
|
|
|
function pruneOlderThan(timestamps, cutoff) {
|
|
// In-place filter (mutating the array end-to-start), faster than
|
|
// building a new array on every request. Returns the surviving count.
|
|
let writeIdx = 0;
|
|
for (let i = 0; i < timestamps.length; i += 1) {
|
|
if (timestamps[i] > cutoff) {
|
|
timestamps[writeIdx] = timestamps[i];
|
|
writeIdx += 1;
|
|
}
|
|
}
|
|
timestamps.length = writeIdx;
|
|
return writeIdx;
|
|
}
|
|
|
|
return function rateLimit(req, res, next) {
|
|
const id = key(req);
|
|
const now = Date.now();
|
|
const cutoff = now - windowMs;
|
|
|
|
let timestamps = hits.get(id);
|
|
if (!timestamps) {
|
|
timestamps = [];
|
|
hits.set(id, timestamps);
|
|
evictIfFull();
|
|
}
|
|
|
|
const remaining = pruneOlderThan(timestamps, cutoff);
|
|
if (remaining >= max) {
|
|
const retryAfterSec = Math.max(1, Math.ceil((timestamps[0] + windowMs - now) / 1000));
|
|
res.set('Retry-After', String(retryAfterSec));
|
|
return res.status(429).json({ error: 'Too many requests' });
|
|
}
|
|
|
|
timestamps.push(now);
|
|
return next();
|
|
};
|
|
}
|
|
|
|
module.exports = { createRateLimit, __internals: { clientIp, MAX_TRACKED_IPS } };
|