@col/rate-limit
Versie: 1.0.0 Toegevoegd: 2026-08-06
Herkomst
Twee apps hadden elk de helft. KCB/src/lib/rate-limit.ts (16 regels) heeft
een schuivend venster — correct, maar ruimt nooit op, dus de map groeit tot
het proces herstart. supplier-onboarding-vercel/src/lib/rate-limit.ts (56
regels) is geparametriseerd (ip, key, max, windowMs) en ruimt bij elke
aanroep op, maar gebruikt een vast venster: bij vijf per minuut kun je er vijf
om 00:59 doen en nog eens vijf om 01:01, omdat de teller per minuutbucket
reset in plaats van over de grens heen te kijken.
Dit item neemt KCB's schuivende venster en combineert dat met de parameters
en de opruiming van supplier-onboarding. Zie
docs/specs/2026-08-06-v10-basisitems.md, sectie 1, voor de volledige
afweging.
De directe aanleiding was geen refactor-wens maar een gat:
@col/auth-pages-starter levert drie POST-routes uit (activate,
forgot-password, reset-password) die al een rateLimit() aanriepen, maar het
bestand daarachter zat niet in het item. Dat compileerde in de bronapp (die
had zelf zo'n bestand geschreven) maar niet in een nieuwe app die alleen de
starter installeerde — het effect is dus geen stil onbeschermd endpoint, maar
een falende build: de import lost niet op. Luidruchtiger dan stil, maar nog
steeds een item dat bij een verse installatie niet draait. Zie
docs/items/auth-pages-starter.md voor hoe dat item vanaf 1.1.0
@col/rate-limit als registryDependency gebruikt.
Waarvoor
Beperkt hoe vaak dezelfde sleutel — meestal ${ip}:<route> — binnen een
tijdvenster mag aanroepen, met een in-memory Map, geen externe
afhankelijkheid. Gebruik het vóór een onauthenticated POST-endpoint dat
gevoelig is voor misbruik: wachtwoord vergeten/resetten, account activeren,
een contactformulier, een externe webhook-ontvanger zonder eigen throttling.
Gebruik het niet als je een garantie nodig hebt die standhoudt over meerdere serverless-instanties heen, of na een process-restart — zie "Bekende beperkingen". Voor dat geval is dit niet het juiste startpunt.
Wat je app moet leveren
Niets — geen props, geen backend-contract, geen omgevingsvariabele. Eén functie, geïmporteerd en direct aangeroepen aan het begin van de route-handler:
import { rateLimit } from '@/lib/rate-limit'
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
const { ok, remaining } = rateLimit(`${ip}:forgot-password`, 3, 15 * 60 * 1000)
if (!ok) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
}
function rateLimit(
key: string,
max: number,
windowMs: number,
): { ok: boolean; remaining: number }
| Parameter | Betekenis |
|---|---|
key |
Volledig door de aanroeper samengesteld, bv. ${ip}:forgot-password. Twee aanroepen met dezelfde sleutel delen dezelfde teller; twee routes die per ongeluk dezelfde sleutel gebruiken, delen ook per ongeluk hun limiet — verzin een sleutel die zowel de identiteit (meestal IP) als de actie bevat. |
max |
Maximum aantal geslaagde aanroepen binnen windowMs. |
windowMs |
Venstergrootte in milliseconden. |
Retourneert { ok: false, remaining: 0 } zodra de limiet is bereikt — een
geblokkeerde aanroep telt zelf niet mee als extra aanroep, dus een
aanhoudende stroom geblokkeerde requests laat de teller niet verder oplopen.
ok: true geeft remaining het aantal nog resterende aanroepen in het
huidige venster (nooit negatief).
Bestanden
| Bestand | Landt in | Soort |
|---|---|---|
rate-limit.ts |
lib/rate-limit.ts |
beheerd |
Geen registryDependencies, geen cssVars. Eén bestand, geen dependencies.
Gebruikt door
| App | Sinds versie | Opmerkingen |
|---|---|---|
@col/auth-pages-starter |
1.1.0 | Als registryDependency — landt automatisch mee op lib/rate-limit.ts bij npx shadcn add @col/auth-pages-starter. Beveiligt de drie POST-routes (activate, forgot-password, reset-password) met sleutels van de vorm ${ip}:<route>. |
Nog niet vanuit dit item geïnstalleerd: supplier-onboarding-vercel en
KCB hebben elk hun eigen, oudere versie van dit bestand (dat is precies
waar dit item uit is samengesteld — zie Herkomst) en zijn nog niet
overgezet naar @col/rate-limit. Wie dat oppakt: voeg hier een regel toe.
Bewuste keuzes
- Eén
key-parameter, niet(ip, key)apart. De aanroeper stelt${ip}:forgot-passwordzelf samen. Dat is één parameter minder, en het maakt expliciet dat de sleutel volledig van de aanroeper is — deze functie weet niets over wat een IP is of hoort te zijn; een aanroeper die op iets anders dan IP wil limiteren (een API-key, een user-id) hoeft niets aan de signatuur te veranderen. - Een geblokkeerde aanroep voegt geen timestamp toe. KCB's origineel
pushte
nowonvoorwaardelijk, ook bij een geblokkeerde aanroep — een aanhoudende stroom aanvragen tijdens de blokkade liet de array van die sleutel dan ongelimiteerd doorgroeien, het exacte lekprobleem dat dit item moet oplossen, alleen dan binnen één sleutel in plaats van over alle sleutels heen. Hier wordt pas gepusht als de aanroep ook echt telt. Zie de test "een geblokkeerde aanroep laat de teller niet verder oplopen" intests/rate-limit.test.ts. - Opruiming per sleutel, met het venster van die sleutel zelf. Elke
invoer in de map onthoudt niet alleen zijn timestamps maar ook de
windowMswaarmee die zijn geschreven ({ timestamps, windowMs }). Bij elke aanroep wordt de hele map doorlopen en verwijderd wat ouder is dan zíjn eigen venster. Dat is bewust niet "verwijder alles ouder dan het venster van déze aanroep" — verschillende sleutels kunnen met verschillendewindowMs-waarden gebruikt worden (auth-routes op 15 minuten, een externe API-route zoals bij KCB op 60 seconden), en zonder deze correctie zou een aanroep met een kort venster de tellers van een sleutel met een lang venster voortijdig kunnen resetten — een limiter die zichzelf omzeilt. - Geen
Map-export om opruiming rechtstreeks te testen. De tests bewijzen het gedrag dat opruiming mogelijk maakt (een verlopen sleutel wordt weer als vers behandeld) via de publiekerateLimit-functie, niet via de private map. Zie de toelichting onderaantests/rate-limit.test.ts. max <= 0geeft meteen{ ok: false, remaining: 0 }terug, zonder de map aan te raken. Zonder deze guard sloeg een verse sleutel een entry met een legetimestamps-array op; de opruimsweep las dan het laatste element van die lege array (undefined),now - undefinedisNaN, enNaN > entry.windowMsis altijdfalse— die entry werd dan nooit meer opgeruimd, voor de rest van de procesduur. Gevonden bij code review (geen van de drie aanroepplekken in@col/auth-pages-startergebruiktmax=0, maar dit item is bedoeld voor consumenten die we niet kennen).max <= 0betekent inhoudelijk ook gewoon "sta niets toe", dus de vroege return is zowel de veilige als de zinnige keuze. Regressietest:tests/rate-limit.test.ts, "max <= 0 raakt de map niet aan" — bewijst via een spy opMap.prototype.setdat er geen entry wordt aangemaakt.
Bekende beperkingen
- Dit werkt in procesgeheugen, niet gedistribueerd. Op Vercel heeft elke
serverless-instantie zijn eigen
Map, dus de feitelijke limiet is per instantie, niet per gebruiker of per IP over de hele deployment heen. Bij meerdere gelijktijdige instanties kan een aanvaller in theorie een veelvoud vanmaxaanroepen doen door pech (of geluk) te hebben met welke instantie elke request afhandelt. Dit remt een naïeve, single-threaded aanval af — het is geen vervanging voor een echte gedistribueerde limiter. Wie dat nodig heeft: Redis (bv.@upstash/ratelimit) of Vercel's eigen rate-limit-voorziening. - De opruimsweep is O(aantal sleutels), niet O(1). Elke aanroep doorloopt de hele map om verlopen sleutels te verwijderen — dat komt letterlijk uit de supplier-onboarding-versie en is dus geen regressie. Op de schaal waarvoor dit bedoeld is (auth-routes per instantie) is dat geen probleem, maar achter een druk endpoint met veel verschillende sleutels (bv. per bezoeker in plaats van per IP) wordt elke aanroep trager naarmate de map groeit.
- Geen persistentie. Een process-restart (deploy, crash, cold start) wist de hele map. Voor de meeste auth-routes is dat acceptabel — de aanvaller begint gewoon opnieuw te tellen, net als bij een gewone koude start.
- Geen enkele registratie van wélke aanroepen geblokkeerd werden. Er is
geen logging, geen metric, geen manier om achteraf te zien hoe vaak de
limiet is geraakt. Wie dat nodig heeft voor monitoring, moet het zelf
rond de
if (!ok)-tak bouwen. - Geen preview-pagina. Er valt niets te zien — dit is een pure functie
zonder UI. Dat is de enige afwijking van de standaardprocedure in
README.mddeel 2; het item staat wel in de lijst op de startpagina, zonderhref, net als@col/brand-assetsen@col/demo-mode-starter.