Laravel RateLimiter a race condition
Proč tooManyAttempts() + hit() v Laravelu selhává proti 100 paralelním requestům — a jak to opravit.
Standardní pattern z dokumentace Laravelu na rate limiting vypadá takhle:
if (RateLimiter::tooManyAttempts('send-message:'.$user->id, $maxAttempts = 5)) {
return 'Too many attempts!';
}
RateLimiter::hit('send-message:'.$user->id);
// Send message...
Funguje to spolehlivě. Dokud někdo na endpoint s povolenými 5 requesty za minutu nepošle naráz 100 paralelních requestů. 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.
Rozeberu, proč je to problém, jak to opravit jedním řádkem a co jsem si z toho odnesl.
Update (10. 6. 2026): Když jsem si tohle ověřil, poslal jsem do oficiálních Laravel docs varování přímo k tomu příkladu. Prošlo, takže sekce "Manually Incrementing Attempts" teď před tou race condition varuje a rovnou ukazuje atomickou opravu. Zbytek článku nechávám tak, jak vznikl.
Proč je to problém?
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áhit(), který count inkrementuje
Dvě nezávislé volání cache. Mezi krokem 1 a krokem 4 je okno - typicky pár mikrosekund - kdy jiný request stihne 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á
hit()→ count = 1 - Request 2 přečte count = 0 (ve stejný moment), projde kontrolou, zavolá
hit()→ 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. A jestli ten endpoint 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.
V captchaapi.eu používám Redis, kde se to mapuje na INCR (resp. INCRBY) - jeden z nejstarších a nejlépe otestovaných příkazů 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 ten zásadní bod: hit() vrací počet po inkrementu. Návratová hodnota je atomická, deterministická, a pro každý paralelní request unikátní.
$hits = RateLimiter::hit($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 oproti tomu separátní read, který klidně vrátí zastaralou hodnotu. A mezi readem a writem se otevírá okno na race condition.
Oprava: jedna inkrementace, kontrola návratové hodnoty
Místo two-step patternu (tooManyAttempts → hit) udělej jen jeden krok:
$attempts = RateLimiter::hit('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 hit() ti ji 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() interně volá increment() s amount = 1). Volba je čistě o č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
V captchaapi.eu používám increment(), protože pro mě je čitelnější, že jde o atomický inkrement - a explicitně to ří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.
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é." Původní pattern z Laravel docs jsem měl v aplikaci nějakou dobu a nikdy nevyplaval žádný bug - prostě proto, že na captchaapi.eu žádný útočník nepřišel.
Race conditions tohoto typu jsou tiché: aplikace logy nehlásí chybu, monitoring vidí "v pořádku", a teprve až se někdo přižene s wrk -c 100, 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(), zahazuješ informaci, kterou ti Redis dal zadarmo.
3. Standardní pattern v dokumentaci ≠ best practice. Laravel docs ten tooManyAttempts() + increment() pattern ukazovaly roky bez jediného varování, od 8.x dál.
Není to chyba - pro většinu webových aplikací (per-user rate limit, kde uživatel reálně nedělá 100 paralelních requestů) je to dostačující.
Ale pro public API, kde je primární threat model právě paralelní útok, dokumentace to nejbezpečnější řešení neukazovala.
Když jsem na to narazil, poslal jsem do docs varování i s ukázkou atomické opravy, takže od 13.x už tam ta poznámka je. I tak si při čtení libovolných docs 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ů. Vyplatí se mít ve feedu lidi, co píšou "narazil jsem na tohle, opravil takhle" - přesně takový pattern matching totiž 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::hit($key);
// ✅ Race-safe - atomický inkrement + kontrola návratové hodnoty
if (RateLimiter::hit($key) > 5) {
return 'Too many!';
}
O jeden řádek méně a jeden problém vyřešený. Pokud máš v aplikaci Laravelovský rate limiter s tooManyAttempts() + hit() patternem, projdi ho a přepiš na single-call variantu - zvlášť když limituješ endpoint, kde je každé volání drahé.