diff --git a/README.md b/README.md index 7708c42..023c23a 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,11 @@ from `src/middleware/rateLimiter.ts` unless overridden by configuration. When `API_KEY` authentication is configured, the presented key identifies the client; open deployments continue to use the client IP. +> **Operational Note (Multi-Instance Deployments):** +> Rate limiter state lives in a plain `Map` local to the middleware instance. In multi-instance deployments without sticky sessions, clients may receive NĂ— their intended budget (where N is the number of replicas), and limits reset entirely upon instance restart. +> +> A shared distributed store (like Redis) is deliberately deferred until a broader persistence layer is introduced to the service, to avoid bloating operational requirements prematurely. However, memory growth is strictly bounded: the internal `Map` is capped at 5000 entries. When capacity is reached, it lazily prunes expired buckets before evicting the oldest entry to protect against memory-pressure attacks. + `POST /api/v1/quote` is excluded from the global limiter via `skipPaths` and then receives its own stricter `rateLimiter({ max: 10, windowMs: 60_000 })` instance in `src/app.ts`. That quote limiter has separate in-memory counters diff --git a/src/middleware/rateLimiter.test.ts b/src/middleware/rateLimiter.test.ts index 7199f68..f969d3d 100644 --- a/src/middleware/rateLimiter.test.ts +++ b/src/middleware/rateLimiter.test.ts @@ -182,4 +182,50 @@ describe("rateLimiter", () => { const blocked = await request(app).post("/api/v1/quote-history"); expect(blocked.status).toBe(429); }); + + it("allows bypass across multiple middleware instances", async () => { + const app = express(); + app.set("trust proxy", true); + + const limiter1 = rateLimiter({ max: 1, windowMs: 1000 }); + app.post("/route1", limiter1, (_req, res) => res.status(201).json({ ok: true })); + + const limiter2 = rateLimiter({ max: 1, windowMs: 1000 }); + app.post("/route2", limiter2, (_req, res) => res.status(201).json({ ok: true })); + + app.use(errorHandler); + + // Client hits route1, consumes quota + await request(app).post("/route1").set("x-forwarded-for", "10.0.0.1").expect(201); + await request(app).post("/route1").set("x-forwarded-for", "10.0.0.1").expect(429); + + // Same client hits route2, gets full quota again + await request(app).post("/route2").set("x-forwarded-for", "10.0.0.1").expect(201); + }); + + it("bounds memory growth by evicting the oldest bucket when capacity is reached", () => { + const limiter = rateLimiter({ max: 1, windowMs: 60000 }); + const next = jest.fn(); + const res = {} as Response; + + const req0 = { method: "POST", ip: "client-0", path: "/mutate" } as unknown as Request; + limiter(req0, res, next); + + limiter(req0, res, next); + expect(next).toHaveBeenLastCalledWith(expect.objectContaining({ status: 429 })); + + // Fill the map up to 5000 (MAX_BUCKETS) + for (let i = 1; i <= 5000; i++) { + const req = { method: "POST", ip: `client-${i}`, path: "/mutate" } as unknown as Request; + limiter(req, res, next); + } + + // Because Client 0 was inserted first, inserting client-5000 triggered eviction of client-0. + // Client 0 should now be granted a new quota. + next.mockClear(); + limiter(req0, res, next); + expect(next).toHaveBeenCalledTimes(1); + expect(next).not.toHaveBeenCalledWith(expect.objectContaining({ status: 429 })); + }); }); + diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index 6b25d14..cad3b8e 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -20,6 +20,9 @@ const DEFAULT_MAX = 30; /** Default rolling window length, in milliseconds. */ const DEFAULT_WINDOW_MS = 60_000; +/** Maximum number of buckets tracked in memory to prevent unbounded growth. */ +const MAX_BUCKETS = 5000; + interface Bucket { count: number; resetAt: number; @@ -77,6 +80,17 @@ export function rateLimiter( const bucket = buckets.get(key); if (!bucket || bucket.resetAt <= now) { + if (!bucket && buckets.size >= MAX_BUCKETS) { + for (const [k, v] of buckets.entries()) { + if (v.resetAt <= now) buckets.delete(k); + } + if (buckets.size >= MAX_BUCKETS) { + const oldestKey = buckets.keys().next().value; + if (oldestKey !== undefined) { + buckets.delete(oldestKey); + } + } + } buckets.set(key, { count: 1, resetAt: now + windowMs }); next(); return;