From a9ef1e5e23ea6e79fe6df9193d64a0666f2cd244 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 18 Feb 2026 13:01:19 +0000 Subject: [PATCH] feat: add fixed-window rate limiter for abuse protection --- src/lib/rate-limit.js | 57 +++++++++++++++++++++++++++++++++++++++++ test/rate-limit.test.js | 37 ++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/lib/rate-limit.js create mode 100644 test/rate-limit.test.js diff --git a/src/lib/rate-limit.js b/src/lib/rate-limit.js new file mode 100644 index 0000000..d266f99 --- /dev/null +++ b/src/lib/rate-limit.js @@ -0,0 +1,57 @@ +"use strict"; + +class FixedWindowRateLimiter { + constructor({ limit, windowMs }) { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error("limit_must_be_positive_integer"); + } + if (!Number.isInteger(windowMs) || windowMs <= 0) { + throw new Error("window_ms_must_be_positive_integer"); + } + + this.limit = limit; + this.windowMs = windowMs; + this.store = new Map(); + } + + hit(key, nowMs) { + const now = Number.isInteger(nowMs) ? nowMs : Date.now(); + const safeKey = key || "anonymous"; + + const current = this.store.get(safeKey); + if (!current || current.windowEnd <= now) { + const next = { + count: 1, + windowEnd: now + this.windowMs, + }; + this.store.set(safeKey, next); + return { + allowed: true, + remaining: this.limit - 1, + retryAfterSec: 0, + }; + } + + current.count += 1; + const allowed = current.count <= this.limit; + + return { + allowed, + remaining: Math.max(0, this.limit - current.count), + retryAfterSec: allowed ? 0 : Math.ceil((current.windowEnd - now) / 1000), + }; + } + + cleanup(nowMs) { + const now = Number.isInteger(nowMs) ? nowMs : Date.now(); + for (const [key, value] of this.store.entries()) { + if (value.windowEnd <= now) { + this.store.delete(key); + } + } + } +} + +module.exports = { + FixedWindowRateLimiter, +}; diff --git a/test/rate-limit.test.js b/test/rate-limit.test.js new file mode 100644 index 0000000..b212618 --- /dev/null +++ b/test/rate-limit.test.js @@ -0,0 +1,37 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { FixedWindowRateLimiter } = require("../src/lib/rate-limit"); + +test("allows requests within window limit", () => { + const limiter = new FixedWindowRateLimiter({ limit: 2, windowMs: 1000 }); + + const first = limiter.hit("u1", 0); + const second = limiter.hit("u1", 1); + + assert.equal(first.allowed, true); + assert.equal(second.allowed, true); + assert.equal(second.remaining, 0); +}); + +test("blocks request over the limit and reports retry delay", () => { + const limiter = new FixedWindowRateLimiter({ limit: 1, windowMs: 1000 }); + + limiter.hit("u1", 0); + const blocked = limiter.hit("u1", 100); + + assert.equal(blocked.allowed, false); + assert.equal(blocked.retryAfterSec > 0, true); +}); + +test("resets after window passes", () => { + const limiter = new FixedWindowRateLimiter({ limit: 1, windowMs: 1000 }); + + limiter.hit("u1", 0); + const blocked = limiter.hit("u1", 500); + const afterWindow = limiter.hit("u1", 1001); + + assert.equal(blocked.allowed, false); + assert.equal(afterWindow.allowed, true); +});