// Bounded rate state and explicit client identity. // // Two separate defects are covered here: // 1. The limiter's map must be hard-bounded. Only evicting *expired* entries // means 20k+ unexpired keys grow state without limit. // 2. Behind a reverse proxy, req.socket.remoteAddress is the proxy's loopback // address, so every user shares one quota. The policy must be explicit: // either trust a configured loopback proxy's forwarded client address, or // state truthfully that the limit is global — never silently trust a // spoofable header from an untrusted peer. import test from 'node:test'; import assert from 'node:assert/strict'; import { createRateLimiter } from '../src/rate-limiter.js'; import { resolveClientIdentity } from '../src/client-identity.js'; test('rate state is hard-bounded under a flood of unexpired distinct keys', () => { const limiter = createRateLimiter({ windowMs: 60_000, maxRequests: 10, maxKeys: 1024 }); const now = Date.now(); for (let i = 0; i < 20_000; i += 1) { limiter.take(`key-${i}`, now); // all within the same, unexpired window } const size = limiter.size(); assert.ok(size <= 1024, `limiter retained ${size} unexpired keys; state must be hard-bounded, not merely expiry-swept`); }); test('a flood of distinct keys never lets a repeat offender escape its own limit', () => { const limiter = createRateLimiter({ windowMs: 60_000, maxRequests: 3, maxKeys: 64 }); const now = Date.now(); // The offender opens its window first. for (let i = 0; i < 3; i += 1) assert.equal(limiter.take('offender', now).allowed, true); assert.equal(limiter.take('offender', now).allowed, false, 'offender must be blocked'); // Now flood far past the cap to try to evict the offender's counter. for (let i = 0; i < 5_000; i += 1) limiter.take(`flood-${i}`, now); const afterFlood = limiter.take('offender', now); assert.equal(afterFlood.allowed, false, 'eviction must not reset an active offender: that would make the limit bypassable by flooding'); }); test('eviction prefers expired entries before evicting live ones', () => { const limiter = createRateLimiter({ windowMs: 1_000, maxRequests: 5, maxKeys: 8 }); const t0 = 1_000_000; for (let i = 0; i < 8; i += 1) limiter.take(`old-${i}`, t0); // Well after those windows expired, new keys must reuse the reclaimed space. for (let i = 0; i < 8; i += 1) limiter.take(`new-${i}`, t0 + 5_000); assert.ok(limiter.size() <= 8); assert.equal(limiter.take('new-0', t0 + 5_000).allowed, true); }); test('the fixed-window boundary cannot be used to double the burst', () => { const limiter = createRateLimiter({ windowMs: 60_000, maxRequests: 10 }); const t0 = 1_000_000; let allowed = 0; // Open the window, then spend the whole budget at the very end of it. if (limiter.take('same', t0).allowed) allowed += 1; for (let i = 0; i < 9; i += 1) if (limiter.take('same', t0 + 59_999).allowed) allowed += 1; assert.equal(allowed, 10, 'the nominal budget must be usable'); // Immediately across the boundary a naive fixed window grants a fresh budget, // allowing 2x the nominal rate within milliseconds of real time. let acrossBoundary = 0; for (let i = 0; i < 10; i += 1) if (limiter.take('same', t0 + 60_000).allowed) acrossBoundary += 1; assert.ok(acrossBoundary < 10, `boundary straddle allowed a full extra budget (${acrossBoundary}), doubling the effective rate`); const total = allowed + acrossBoundary; assert.ok(total <= 14, `${total} requests accepted across a ~1ms boundary straddle against a 10-per-60s budget`); }); test('sustained load stays near the nominal rate rather than bursting to 2x each boundary', () => { const limiter = createRateLimiter({ windowMs: 1_000, maxRequests: 5 }); let accepted = 0; // Ten seconds of continuous pressure at 50 requests/second. for (let ms = 0; ms < 10_000; ms += 20) { if (limiter.take('steady', 500_000 + ms).allowed) accepted += 1; } // 10s at 5/s is 50; allow a small margin but not a 2x boundary doubling. assert.ok(accepted <= 60, `sustained acceptance ${accepted} exceeded the nominal 5/s budget envelope`); assert.ok(accepted >= 40, `sustained acceptance ${accepted} is unusably far below the nominal budget`); }); test('client identity behind an untrusted peer never trusts forwarded headers', () => { const identity = resolveClientIdentity({ remoteAddress: '203.0.113.7', headers: { 'x-forwarded-for': '198.51.100.9', 'x-real-ip': '198.51.100.10' }, trustedProxies: [], }); assert.equal(identity.key, '203.0.113.7', 'the peer address is the only trustworthy identity here'); assert.equal(identity.scope, 'peer'); assert.equal(identity.trustedProxy, false); }); test('a configured trusted loopback proxy contributes the forwarded client address', () => { const identity = resolveClientIdentity({ remoteAddress: '127.0.0.1', headers: { 'x-forwarded-for': '198.51.100.9, 10.0.0.5' }, trustedProxies: ['127.0.0.1'], }); assert.equal(identity.key, '198.51.100.9', 'the left-most forwarded address is the client'); assert.equal(identity.scope, 'forwarded'); assert.equal(identity.trustedProxy, true); }); test('a trusted proxy that forwards nothing usable degrades to a truthful global policy', () => { const identity = resolveClientIdentity({ remoteAddress: '127.0.0.1', headers: {}, trustedProxies: ['127.0.0.1'], }); assert.equal(identity.scope, 'global', 'without a usable forwarded address the quota is shared; the policy must say so'); assert.equal(identity.key, 'global'); }); test('forwarded addresses are validated, not echoed', () => { for (const spoof of ['not-an-ip', '', ' ', '999.999.999.999', '