diff --git a/server/ratelimit.js b/server/ratelimit.js new file mode 100644 index 0000000..0738f21 --- /dev/null +++ b/server/ratelimit.js @@ -0,0 +1,23 @@ +// In-Memory Rate-Limiter pro Key (Sliding Fixed-Window). +// ponytail: per-Prozess, resettet bei Container-Restart — bei Magic-Link (kein +// Passwort-Brute-Force) akzeptierte Ceiling; Persistenz = YAGNI. +const buckets = new Map(); + +/** + * Zählt einen Treffer für `key`. Gibt true zurück, solange das Limit im Fenster + * nicht überschritten ist, sonst false (→ Aufrufer antwortet 429). + */ +function hit(key, limit, windowMin) { + const now = Date.now(); + let b = buckets.get(key); + if (!b || now >= b.resetAt) { + b = { count: 0, resetAt: now + windowMin * 60 * 1000 }; + buckets.set(key, b); + } + b.count += 1; + return b.count <= limit; +} + +function _reset() { buckets.clear(); } // nur für Tests + +module.exports = { hit, _reset }; diff --git a/server/ratelimit.test.js b/server/ratelimit.test.js new file mode 100644 index 0000000..061bbef --- /dev/null +++ b/server/ratelimit.test.js @@ -0,0 +1,18 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const { hit, _reset } = require('./ratelimit'); + +test('erlaubt bis zum Limit, blockt danach', () => { + _reset(); + assert.strictEqual(hit('a@x.de', 3, 15), true); + assert.strictEqual(hit('a@x.de', 3, 15), true); + assert.strictEqual(hit('a@x.de', 3, 15), true); + assert.strictEqual(hit('a@x.de', 3, 15), false, '4. Anfrage geblockt'); +}); + +test('verschiedene Keys sind unabhängig (E-Mail vs IP)', () => { + _reset(); + assert.strictEqual(hit('email:a@x.de', 1, 15), true); + assert.strictEqual(hit('email:a@x.de', 1, 15), false); + assert.strictEqual(hit('ip:1.2.3.4', 100, 15), true, 'anderer Key unberührt'); +});