Laravel RateLimiter a race condition
Proč tooManyAttempts() + hit() v Laravelu selhává proti 100 paralelním requestům — a jak to opravit.
Jeden z manuálních patternů na rate limiting, který ukazuje dokumentace Laravelu (v sekci Manually Incrementing Attempts), vypadá takhle:
if (RateLimiter::tooManyAttempts('send-message:'.$user->id, $maxAttempts = 5)) {
return 'Too many attempts!';
}
RateLimiter::increment('send-message:'.$user->id);
// Send message...
Funguje to spolehlivě — dokud někdo na endpoint, který má povolených 5 requestů za minutu, neudeří 100 paralelními requesty zároveň. Pak ti všech 100 projde.
Tohle je race condition, na kterou jsem narazil při implementaci rate limitingu pro captchaapi.eu (PoW CAPTCHA API). Inspiraci k odhalení mi dal post na X od @_newtonjob, který přesně tento problém pojmenoval:
Your Ratelimiting logic works until someone fires 100 concurrent requests on an endpoint that should be limited to 5 requests per minute. The fix: Ensure you/your agents also check the incremented count returned by
RateLimiter::hit()and that it doesn't exceed the max attempts.
(Pozn.: hit() a increment() jsou aliasy — hit() je doslova jednořádkový wrapper, který volá increment(). Laravel docs ukazovaly příklad s hit() v 8.x a 9.x, od 10.x dál ukazují increment(), ale obě metody pořád fungují a mají identické chování.)
V tomto článku rozeberu, proč je to problém, jak to opravit jedním řádkem a co jsem si z toho odnesl.
Proč je to problém?
Pojďme se podívat, co se stane při jednom requestu:
tooManyAttempts()přečte z cache aktuální count- Porovná ho s
$maxAttempts - Vrátí
true/false - Pokud
false, kód zavoláincrement(), který count navýší
Dvě nezávislé volání cache. Mezi krokem 1 a krokem 4 je okno — typicky pár mikrosekund — kdy jiný request může přečíst stejnou starou hodnotu, projít kontrolou a taky inkrementovat.
Při 100 paralelních requestech se to děje masivně:
- Request 1 přečte count = 0, projde kontrolou (0 < 5), zavolá
increment()→ count = 1 - Request 2 přečte count = 0 (ve stejný moment), projde kontrolou, zavolá
increment()→ count = 2 - ... a tak dál pro všech 100 requestů
Výsledek: counter na konci ukazuje 100, ale všech 100 requestů už proběhlo a tvůj backend zpracoval 100× víc, než chtěl. Pokud jde o endpoint, který něco draze počítá (PoW challenge, AI inference, externí API call), právě jsi zaplatil za 100 operací místo 5.
Proč inkrement chrání atomicky, ale check ne
Když se podíváš do zdrojáku Laravelu (Illuminate\Cache\RateLimiter::increment() v 12.x):
public function increment($key, $decaySeconds = 60, $amount = 1)
{
$key = $this->cleanRateLimiterKey($key);
$this->cache->add(
$key.':timer', $this->availableAt($decaySeconds), $decaySeconds
);
$added = $this->withoutSerializationOrCompression(
fn () => $this->cache->add($key, 0, $decaySeconds)
);
$hits = (int) $this->cache->increment($key, $amount);
// ...
return $hits;
}
A hit() je jen alias pro increment():
public function hit($key, $decaySeconds = 60)
{
return $this->increment($key, $decaySeconds);
}
Klíčové je $this->cache->increment($key, $amount) — to je atomická operace v cache backendu.
Já v captchaapi.eu používám Redis a tam se to mapuje na INCR (resp. INCRBY), což je jeden z nejstarších a nejlépe testovaných příkazů v Redisu — atomický na úrovni single-key write.
Žádné dva paralelní requesty si nepřečtou stejnou hodnotu, vždycky dostanou unikátní inkrementovaný výsledek. Memcached má ekvivalent incr se stejnou atomicitou.
A tady je ta klíčová věc: increment() vrací počet po inkrementu. Návratová hodnota je atomická, deterministická, a pro každý paralelní request unikátní.
$hits = RateLimiter::increment($key);
// Při 100 concurrent requestech dostaneš návratové hodnoty 1, 2, 3, ..., 100
// (v náhodném pořadí, ale každá hodnota právě jednou)
tooManyAttempts() je naproti tomu separátní read, který může vrátit zastaralou hodnotu. Mezi readem a writem je okno na race condition.
Oprava: jedna inkrementace, kontrola návratové hodnoty
Místo two-step patternu (tooManyAttempts → increment) udělej jen jeden krok:
$attempts = RateLimiter::increment('send-message:'.$user->id);
if ($attempts > $maxAttempts) {
return 'Too many attempts!';
}
// Send message...
Co se stane teď při 100 paralelních requestech:
- Každý request dostane unikátní count po inkrementu
- Prvních 5 dostane hodnoty 1-5 a projde
- Zbylých 95 dostane hodnoty 6-100 a budou odmítnuty
Žádné okno, žádná race condition. Atomický inkrement Redisu je tady jediný zdroj pravdy a increment() ti tu pravdu vrátí přímo.
Subtilní rozdíl: v původním patternu se inkrementuje po checku, takže overshoot zůstane v counteru ("counter ukazuje 6 i když jsme nechtěli povolit 6. request"). V novém patternu inkrementuješ vždy, ale kontroluješ návratovou hodnotu, takže counter může klidně ukazovat 100 — to je v pořádku, protože nadbytečné requesty jsi odmítl. Z hlediska business logiky je to ekvivalentní; z hlediska bezpečnosti je nový pattern přísnější.
hit() vs. increment() — který použít?
Z hlediska funkce jsou identické — hit() je doslova function hit($key, $decay) { return $this->increment($key, $decay); }. Aktuální Laravel docs (10.x+) ukazují v příkladu manuálního inkrementu increment(); starší verze (8.x, 9.x) používaly hit(). Obě fungují, vyber si podle čitelnosti:
hit()— sémanticky "registruji jednu událost", hodí se pro klasické rate limitingincrement()— sémanticky "atomicky zvyš counter o N", hodí se když chceš zdůraznit atomicitu nebo navýšit o víc než 1 přesamount:parametr
V captchaapi.eu používám increment(), protože odpovídá aktuálním docs a explicitně říká, že mě zajímá návratová hodnota, ne side effect.
Limity tohoto řešení
Důležité přiznání: tohle řešení chrání proti race condition v rámci single Redis instance (nebo Redis clusteru, kde každý key žije na jednom shardu).
Pokud bys měl rozdělené Redis instance pro různé regiony bez koordinace a útočník by triggeroval requesty napříč regiony, atomicita INCR by ti ochranu nezajistila — měl bys 100 requestů × N regions.
V captchaapi.eu mi tohle stačí, protože celá aplikace běží proti single Redisu (Hetzner Nuremberg). Pro multi-region s distribuovaným rate limitingem bys potřeboval algoritmus typu: sliding window log nebo token bucket s centralizovaným zdrojem pravdy — to je jiný topic.
Druhý známý limit: tohle chrání proti race condition na úrovni counteru. Pokud útočník mění IP (botnet, residential proxy), žádný per-IP rate limit ho nezastaví — to je fundamentálně jiný problém, který v captchaapi.eu řeším právě tou PoW challenge.
Ještě jedna věc stojí za to říct explicitně: pro HTTP routy je v Laravelu doporučený způsob throttle middleware, ne manuální rate-limiting kód. Tenhle článek je specificky o manuálním patternu, který používáš, když rate-limituješ ne-HTTP operace, vlastní logiku uvnitř controlleru nebo cokoli, kde se ti throttle middleware nehodí.
Co jsem si z toho odnesl
Tahle drobná oprava mi ukázala několik věcí, které mi do té doby nedocházely:
1. "Funguje to" ≠ "je to bezpečné." Dokumentovaný manuální pattern jsem měl v aplikaci nějakou dobu a nikdy se neprojevil žádný bug — protože útočník na captchaapi.eu nepřišel.
Race conditions tohoto typu jsou tiché: aplikace logy nehlásí chybu, monitoring vidí "v pořádku", a teprve když se někdo s wrk -c 100 přijde podívat, zjistíš, že limit nikdy reálně neplatil.
Příští code review v aplikaci, kde nějak rate-limituji drahou operaci, začínám otázkou: "Co se stane, když přijde 100 requestů ve stejném mikrosekundovém okně?"
2. Návratové hodnoty z atomických operací jsou kód, který nepíšeš. Atomický inkrement v Redisu ti vrací unikátní pořadové číslo zdarma.
To se dá použít nejen na rejection, ale i na další business logiku, kterou bys jinak řešil dalším kódem a dalšími locky.
Když máš atomický counter a používáš jen tooManyAttempts() a ignoruješ návratovou hodnotu z increment(), zahazuješ informaci, kterou ti Redis dal zadarmo.
3. Dokumentovaný manuální pattern není vždycky ten nejbezpečnější. Laravel docs ukazují tooManyAttempts() + increment() (resp. hit() ve starších verzích) pod sekcí "Manually Incrementing Attempts" ve všech verzích od 8.x po 12.x.
Není to chyba — pro většinu use-casů (per-user rate limit, kde uživatel reálně nedělá 100 paralelních requestů) je to dostačující.
Ale pro situace, kde je primární threat model právě paralelní útok, dokumentace neukazuje to nejbezpečnější řešení.
Při čtení docs si od té doby vždycky kladu otázku: "Kdo je tady předpokládaný uživatel a sedí jeho threat model na můj?"
4. Bezpečnostní povědomí čerpám z X. Tahle konkrétní oprava se ke mně dostala přes 280-znakový post @_newtonjob, ne přes Laravel docs ani security audit. Sledování členů Laravel komunity, kteří sdílí konkrétní patternové chyby z reálných aplikací, mi dává víc než většina security blog postů. Stojí za to mít v feedu lidi, kteří píšou "narazil jsem na X, opravil takhle" — protože přesně takový pattern matching potřebuješ pro vlastní kód. Díky, @_newtonjob.
Shrnutí
// ❌ Náchylné na race condition — 100 paralelních requestů projde
if (RateLimiter::tooManyAttempts($key, 5)) {
return 'Too many!';
}
RateLimiter::increment($key);
// ✅ Race-safe — atomický inkrement + kontrola návratové hodnoty
if (RateLimiter::increment($key) > 5) {
return 'Too many!';
}
O jeden řádek méně a jeden problém vyřešený. Pokud máš v aplikaci Laravelovský rate-limiting kód s tooManyAttempts() + increment() (nebo hit()) two-step patternem, projdi ho a přepiš na single-call variantu — zvlášť pokud limituješ nějakou operaci, kde je každé volání drahé.