Skip to content

Commit bcdb151

Browse files
Merge pull request #737 from ugoocreates-pixel/feature/issue-045-fingerprint-ratelimit
Feature/issue 045 fingerprint ratelimit
2 parents d4fe009 + fc8fdff commit bcdb151

4 files changed

Lines changed: 34 additions & 0 deletions

File tree

src/config/env-schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ const baseSchema = z.object({
8585
INVITES_RATE_LIMIT_CAPACITY: z.coerce.number().int().positive().default(60),
8686
INVITES_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
8787

88+
// ── Fingerprint rate limiting (per user, token bucket) ────────
89+
FINGERPRINT_RATE_LIMIT_CAPACITY: z.coerce.number().int().positive().default(60),
90+
FINGERPRINT_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
91+
8892
// ── Settle confirmer ──────────────────────────────────────
8993
SETTLE_CONFIRMER_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(5_000),
9094
SETTLE_CONFIRMER_CONFIRMATION_LEDGERS: z.coerce.number().int().positive().default(2),

src/middleware/rateLimit.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,3 +378,12 @@ export const webhooksRateLimiter = createPerUserTokenBucketLimiter({
378378
refillWindowMs: env.WEBHOOKS_RATE_LIMIT_WINDOW_MS,
379379
keyGenerator: getUserRateKey,
380380
});
381+
382+
/**
383+
* Pre-configured per-user rate limiter for `/api/fingerprint` route.
384+
*/
385+
export const fingerprintRateLimiter = createPerUserTokenBucketLimiter({
386+
capacity: env.FINGERPRINT_RATE_LIMIT_CAPACITY,
387+
refillWindowMs: env.FINGERPRINT_RATE_LIMIT_WINDOW_MS,
388+
keyGenerator: getUserRateKey,
389+
});

src/routes/fingerprint.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
import { Router, type Request, type Response, type NextFunction } from "express";
2929
import { buildFingerprintInputs, computeFingerprint } from "../middleware/fingerprint";
30+
import { fingerprintRateLimiter } from "../middleware/rateLimit";
3031
import { getCorrelationId } from "../middleware/correlation";
3132
import { logger } from "../config/logger";
3233
import { getRequestId } from "../lib/requestContext";
@@ -42,6 +43,7 @@ export const fingerprintRouter = Router();
4243
*/
4344
fingerprintRouter.get(
4445
"/",
46+
fingerprintRateLimiter,
4547
(req: Request, res: Response, next: NextFunction): void => {
4648
const correlationId = getCorrelationId() ?? "unknown";
4749
const reqId = getRequestId() ?? "unknown";

tests/fingerprint.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,25 @@ describe("GET /api/fingerprint", () => {
430430
]);
431431
expect(r1.body.fingerprint).toBe(r2.body.fingerprint);
432432
});
433+
434+
it("enforces per-user token-bucket rate limit with 429 and Retry-After", async () => {
435+
const app = createApp();
436+
const limit = 60; // default FINGERPRINT_RATE_LIMIT_CAPACITY
437+
const isolatedIp = "9.9.9.9";
438+
439+
// Exhaust the rate limit for this IP
440+
for (let i = 0; i < limit; i++) {
441+
await request(app).get("/api/fingerprint").set("x-forwarded-for", isolatedIp);
442+
}
443+
444+
// The 61st request should be blocked
445+
const res = await request(app).get("/api/fingerprint").set("x-forwarded-for", isolatedIp);
446+
447+
expect(res.status).toBe(429);
448+
expect(res.headers["retry-after"]).toBeDefined();
449+
expect(res.body.error).toBeDefined();
450+
expect(res.body.error.code).toBe("rate_limit_exceeded");
451+
});
433452
});
434453

435454
// ── 35: fingerprintMiddleware error handling ────────────────────────────

0 commit comments

Comments
 (0)