Rate limiting er en av de tingene som føles løst i det øyeblikket du skriver token-bøtten og ser testen bestå. Min besto hver eneste test jeg skrev for den. I produksjon, bak Caddy, delte hver eneste besøkende på siden én bøtte, ti forespørsler i minuttet, for hele siden samlet. Ikke per person. Totalt.

selve limiteren var aldri buggen
Den faktiske bøttelogikken er en liten, in-memory, per-nøkkel token-bøtte, ikke noe fancy, fylles opp over tid opp til en burst lik forespørslene-per-minutt-raten:
func (l *Limiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
b, ok := l.buckets[key]
if !ok {
l.buckets[key] = &bucket{tokens: l.burst - 1, lastSeen: now}
return true
}
elapsed := now.Sub(b.lastSeen).Seconds()
b.tokens += elapsed * l.rate
if b.tokens > l.burst {
b.tokens = l.burst
}
b.lastSeen = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}Den koden er korrekt og har ikke endret seg. Buggen lå helt og holdent i hva som ble sendt inn som key.
hvordan jeg nøklet den, og hvorfor den så fin ut lokalt
Den naive versjonen nøkler rent på r.RemoteAddr, den rå socket-adressen Gos egen HTTP-server ser for tilkoblingen:
// what I shipped first
func limiterKey(r *http.Request) string {
return r.RemoteAddr
}Lokalt, kjørende alt gjennom docker compose up på laptopen min, er det akkurat nettleserens egen adresse, 127.0.0.1 eller nær der, én maskin, én bøtte, fungerer akkurat som forventet i hver manuelle test jeg kjørte.
Topologien bak Caddy i en faktisk deploy er ikke det. Hver forespørsel til blog-tjenesten ankommer gjennom frontend-containeren som fungerer som en intern reverse-proxy, selv sittende bak Caddy, selv vendt mot internett. RemoteAddr ved blog-tjenesten er frontend-containerens adresse, for hver eneste besøkende, fordi det er genuint hvem som åpnet TCP-tilkoblingen til blog-tjenesten fra dens synspunkt. En nøkkel basert på det er ikke per-besøkende i det hele tatt, det er per-hopp, og det er nøyaktig ett hopp mellom "hver besøkende på internett" og "blog-tjenesten." En per-IP-limiter bygget på den nøkkelen er en de facto sidebred limiter, og den forble usynlig i hver lokal test fordi det lokalt ikke er noe proxy-hopp å gjemme seg bak i utgangspunktet, buggen eksisterer bare i topologien jeg aldri testet direkte mot.
standardsvaret, og fellen inni det
Standardfiksen for "adressen jeg ser er ikke den ekte klienten" er X-Forwarded-For, en header proxyer legger til med den originale klientens adresse mens de videresender en forespørsel. Å stole blindt på den er sin egen velkjente felle likevel: X-Forwarded-For er en request-header, og hvilken som helst klient kan sette den til bokstavelig talt hva som helst før Caddy noensinne ser forespørselen, inkludert en falsk verdi valgt spesifikt for å kollidere med noen andres rate-limit-bøtte, eller for å unngå sin egen. Du kan ikke bare lese den og stole på den, du må vite hvilken del av den, om noen, din egen infrastruktur faktisk skrev.
Regelen jeg landet på: stol på nøyaktig én verdi, og bare når det er nøyaktig én verdi å stole på.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" && !strings.Contains(xff, ",") {
if ip := strings.TrimSpace(xff); ip != "" {
return ip
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}En enkelt-verdi-header betyr at blog-tjenestens egen upstream (frontend-proxyen, og bare frontend-proxyen) skrev den fersk. Alt med et komma i seg, flere hopp lenket sammen, er akkurat hva en klient som spoofer headeren utenfra ville produsert, siden en ekte klient ikke har noe tidligere hopp som kunne lagt til noe før sin egen falske verdi, så den formen faller tilbake til RemoteAddr i stedet for å bli nøklet på i det hele tatt.
den andre halvparten av fiksen bor ett hopp tidligere
Ingenting av det fungerer med mindre noe upstream faktisk skriver om headeren til én pålitelig verdi i stedet for bare å videresende hva enn nettleseren sendte. Det er frontend-proxyens jobb, og den gjør det med vilje, ikke ved et uhell:
const inboundXff = event.request.headers.get('x-forwarded-for');
let clientAddress = '';
try {
clientAddress = event.getClientAddress();
} catch {
// no trustworthy client address for this request (e.g. a direct
// loopback healthcheck bypassing Caddy) - fall through to sending none
}
const forwardedFor = resolveForwardedFor(inboundXff, clientAddress);
if (forwardedFor) headers.set('x-forwarded-for', forwardedFor);resolveForwardedFor tar den siste oppføringen av hva som kom inn (den Caddy selv la til, pålitelig uansett hva en klient satte foran tidligere i kjeden) eller faller tilbake til frontendens egen løste klientadresse, og sender alltid ut nøyaktig én verdi. Blog-tjenestens enkelt-verdi-sjekk holder bare fordi dette tidligere hoppet garanterer det, aldri videresender en flerverdi-kjede selv. To tjenester, to små biter logikk, og hele greia fungerer bare fordi hver stoler på laget rett bak seg og ingenting lenger tilbake enn det.
hva som faktisk avslørte det
Ikke en rapport fra en sint bruker som ble urettferdig rate-limitet, som var det jeg forventet ville flagge dette til slutt. Det kom opp under en gjennomgangsrunde mens jeg jobbet gjennom innloggings- og passordendring-endepunkter, sporet nøyaktig hvem som kunne se hvilken adresse ved hvert hopp, og innså at tallet "10 forespørsler i minuttet" var sant, bare sant for feil populasjon. Når du sier det høyt, "hver besøkende på siden deler én bøtte," høres det åpenbart ødelagt ut. Å komme dit krevde faktisk å tegne forespørselsveien hopp for hopp i stedet for å stole på at en rate-limiter som besto sine enhetstester gjorde jobben i topologien den faktisk ville kjøre i.
samme fiks, to ganger
Blog-tjenesten er ikke det eneste stedet denne eksakte formen av bug bodde. Auth-tjenesten holder sin egen separate kopi av samme limiter-pakke i stedet for å dele en modul med blog-tjenesten (hele denne kodebasen dupliserer bevisst en håndfull småting som det, JWT-claims-structen er en annen, i stedet for å trekke inn en delt intern pakke for et dusin linjer kode), og dens innloggings- og passordendring-endepunkter hadde det identiske ett-hopp-bak-Caddy-problemet, nøklet på samme feil måte av samme grunn. Å fikse den ene og ikke den andre ville betydd at ett endepunkt håndhevet en ekte per-besøkende-grense mens et søskenendepunkt to tjenester bortover forble stille globalt. Når jeg først forsto den faktiske formen av buggen tok det bare noen minutter å sjekke om den fantes andre steder samme forespørselsvei berørte. Å skrive dette innlegget tok mye lenger tid enn noen av fiksene gjorde.
den kjedelige lærdommen under den interessante
En rate-limiter med en korrekt token-bøtte og en feil nøkkel er ikke halvveis ødelagt, den er fullstendig ødelagt, den feiler bare i en retning som ser ut som suksess utenfra, forespørsler blir tillatt eller nektet, tall beveger seg, ingenting kaster en feil. Det er den genuint farlige formen for denne typen bug å ta. En limiter som krasjet rett ut ville blitt fanget første gang noen traff endepunktet lokalt. En som stille beskytter feil grense fortsetter å fungere, helt til grensen den faktisk skulle beskytte blir truffet av noen som ikke leker snilt.