Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions src/middleware/rateLimiter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
});
});

14 changes: 14 additions & 0 deletions src/middleware/rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down