Laravel belgelerinde gösterilen manuel rate-limiting örneklerinden biri ( Manually Incrementing Attempts bölümünde) şu şekildedir:
if (RateLimiter::tooManyAttempts('send-message:'.$user->id, $maxAttempts = 5)) {
return 'Too many attempts!';
}
RateLimiter::increment('send-message:'.$user->id);
// Send message...
Yukarıdaki kod sorunsuz çalışabilir. Ancak biri saniyede 5 istekle sınırlı bir endpoint’e 100 eş zamanlı istek gönderirse, tüm 100 istek geçer.
Bu yarış durumu, captchaapi.eu, bir Proof-of-Work (PoW) CAPTCHA API’si inşa ederken başıma geldi. Bu konuda @_newtonjob’a teşekkür ederim, çünkü bunu 280 karakterde harika bir şekilde özetlemiş:
Rate limiting mantığınız, sınırlı olmalı olan bir endpoint’e 100 eş zamanlı istek gönderildiğinde işler. Çözüm:
RateLimiter::hit()tarafından döndürülen artırılmış sayıyı kontrol edin ve bunun maksimum deneme sayısını aşmadığından emin olun.
Burada bir sorun var: Kurulum şekli, bir adet düzeltme ve çıkardığım dersler ile bu durumu açıklayacağım.
Neden bir sorun?
Neden bir sorun?
Tek bir isteği değerlendirelim:
tooManyAttempts()mevcut sayıyı cache’den okur.- Bu sayıyı
$maxAttemptsile karşılaştırır. trueveyafalsedöner.- Eğer
falseise, kodincrement()fonksiyonunu çağırarak sayıyı artırır.
Bu, iki bağımsız cache çağrısıdır. İlk ve son adım arasında, başka bir isteğin aynı güncel değeri okuma ihtimali vardır ve bu da kontrolü geçip artırma işlemi yapma penceresi yaratır.
100 eş zamanlı istekte bu durum ölçeklenerek gerçekleşir:
- İstek 1 sayıyı 0 olarak okuyup kontrolü geçer (0 -> 1)
- İstek 2 de aynı anda 0 sayısını okur, kontrolü geçer ve
increment()çağrılır (0 -> 2) - …böyle devam eder ve 100’e kadar çıkar.
Sonuçta sayaç 100 olur, ama bunun üzerinden 100 istek zaten gerçekleşmiştir ve backend’iniz istediğiniz kadar işin 100 katını işlemiştir. Eğer endpoint ağır bir işlem (bir PoW zorluğu, AI işlemesi, dış API çağrısı) yapıyorsa, 5 yerine 100 işlemlik bir ücret ödemiş olursunuz.
Neden artırma atomiktir ama kontrol değil?
Neden artırma atomiktir ama kontrol değil?
Laravel kaynak kodunu açtığınızda (Illuminate\Cache\RateLimiter::increment() 12.x’te):
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;
}
Ve hit() de tam olarak increment() ile aynıdır:
public function hit($key, $decaySeconds = 60)
{
return $this->increment($key, $decaySeconds);
}
Buradaki önemli nokta: $this->cache->increment($key, $amount) bir atomik işlemdir.
captchaapi.eu’da Redis kullanıyorum, burada bu INCR (veya INCRBY) komutları ile eşleşir ve Redis’in en eski ve en sağlam komutlarından biridir. Tek anahtar yazımı düzeyinde atomiktir: iki eş zamanlı istek aynı değeri okuyamaz ve her biri benzersiz artan sonuca sahiptir. Memcached de aynı garantileri sağlayan bir incr komutuna sahiptir.
Buradaki önemli şey: increment() artıştan sonraki sayıyı döner. Dönen değer atomik, belirleyici ve her eş zamanlı çağırıcı için benzersizdir.
$hits = RateLimiter::increment($key);
// 100 eş zamanlı istekte geri dönüş değerleri 1, 2, 3, ..., 100 olarak elde edilir
// (rastgele sırada, ama her değer tamı tamına bir kez)
Diğer yandan, tooManyAttempts() ayrı bir okuma işlemidir. Güncel olmayan bir değer dönebilir ve bu okuma işlemi ile sonraki yazma işlemi arasındaki boşluk sizin yarış pencerenizi ortaya çıkarır.
Çözüm: Tek artış, dönüş değerini kontrol et
Çözüm: Tek artış, dönüş değerini kontrol et
İki adımlı desenin gereğini ( tooManyAttempts → increment) bırakın ve tek bir çağrı yapın:
$attempts = RateLimiter::increment('send-message:'.$user->id);
if ($attempts > $maxAttempts) {
return 'Too many attempts!';
}
// Mesajı gönder...
Artık 100 eş zamanlı istekle:
- Her istek artıştan sonra benzersiz bir sayıya ulaşır.
- İlk 5 istek 1-5 değerlerini alır ve geçer.
- Kalan 95 istek 6-100 değerlerini alır ve reddedilir.
Artık pencere yok, yarış durumu yok. Redis’in atomik artışı tek gerçeklik kaynağıdır ve increment() size bu gerçeği doğrudan sağlar.
Belirtmek gereken ince bir ayrıntı: orijinal desende, artış kontrol sonrası gerçekleşir, bu nedenle herhangi bir aşım sayıda kalır (“sayacın 6 göstermesi ancak 6. isteğe izin vermek istemek”). Yeni desende her seferinde artış yapıyorsunuz ve dönüş değerini kontrol ediyorsunuz; bu nedenle sayaç 100 gösterebilir. Bu da sorun değildir çünkü limitin üzerindeki her şey reddedildi. Sayaç gösterimi aynı zamanı işaret edebilir, ancak güvenlik modeli daha sıkıdır.
hit() ve increment(): hangisini seçmeli?
hit() ve increment(): hangisini seçmeli?İkisi de aynı şeyi yapar. hit() tam olarak function hit($key, $decay) { return $this->increment($key, $decay); } şeklindedir. Mevcut Laravel belgeleri (10.x ve üzeri) örneklerinde increment() kullanıyor; daha eski versiyonlar (8.x, 9.x) hit() kullanıyordu. İkisi de çalışır, hangisi daha doğal geliyorsa onu seçin:
-
hit(): “bir olayı kaydet” mantığı, rate limiting ile doğal bir uyum sağlar. -
increment(): atomik sayaç yönüne veyaamount:parametresi ile birden fazla artırmayı vurgulamak istediğinizde tercih edin.
captchaapi.eu’da increment() kullanmayı tercih ettim, çünkü güncel belgelerle eşleşiyor ve dönüş değerine önem verdiğimi açıkça gösteriyor.
Bu durum nerede işe yaramaz?
Bu durum nerede işe yaramaz?
Bunun bazı açık sınırlamaları var: bu, sadece tek bir Redis örneğinde gerçekleşen yarışları korur (ya da Redis kümesinde, yani her anahtarın bir parçacığın üzerinde yaşadığı durumda). Eğer Redis’i bölgeler arasında koordine olmadan böler ve bir saldırgan her bölgede istekler başlatırsa, INCR atomikliği size yardımcı olamaz. Her bölgede 100 istek alırsınız, N bölgesi kadar.
captchaapi.eu’da bu yeterlidir çünkü tüm uygulama tek bir Redis’e (Hetzner Nuremberg) karşı çalışır. Çok bölgeli dağıtılmış rate limiting için kaydeden bir log veya merkezi bir bilgi kaynağı ile token bucket gibi bir yapı gereklidir. Farklı bir konudur.
Bir diğer limit: bu, sayaç düzeyindeki yarışlar için bir düzeltmedir. Bir saldırgan IP’lerini değiştirirse (botnet, konut proxy’si), herhangi bir IP bazlı rate limit bunu engelleyemez. Bu farklı bir sorundur ve captchaapi.eu’da bunu PoW zorluğu ile ele alıyorum.
Bir şey daha açıkça belirtmek gerekirse: HTTP rotaları için Laravel’daki önerilen yol throttle middleware kullanmaktır, manuel rate-limiting kodu değil. Bu gönderi, manuel desensiz üzerinde duruyor, bu da rate-limiting’i HTTP dışı işlemler, bir kontrolördeki özelleştirilmiş mantık ya da throttle middleware’nin uygun olmadığı herhangi bir şey için başvurduğunuzdeseni anlatıyor.
Çıkardığım dersler
Çıkardığım dersler
Küçük bir düzeltme, ancak benim için bazı şeyler yeni anlam kazandı.
1. “Çalışıyor” ile “güvenli” aynı şey değildir. Üzerinde belgelenmiş manuel desenle üretimde bir süre çalıştım ve bir hata görmedim, çünkü captchaapi.eu’ya hiç saldırgan gelmedi. Bu yarışlar sessizdir. Uygulama logları hiç bir şey söylemez, izleme yeşil gösterir ve limitlerin aslında tutulmadığını ancak biri wrk -c 100 ile geldiğinde öğrenirsiniz. Artık pahalı bir işlem için rate-limiting kodunu gözden geçirdiğimde, başlayarak: “100 isteğin aynı mikrosaniyede gelmesi durumunda ne olur?” diye düşünüyorum.
2. Atomik işlemlerden dönen değer, yazmam gereken kod değil. Redis, her atomik artışla sizin için benzersiz bir sıra numarası verir. Reddetmek için kullanabilirsiniz, ancak başka iş mantıkları için de kullanabilirsiniz, aksi takdirde daha fazla kod ve kilit ile çözülecek bir durumdur. tooManyAttempts() kullanıp increment()‘ın dönüş değerini görmezden gelmek, Redis’in zaten size verdiği bilgiyi fırlatmak demektir.
3. Belgelenmiş manuel desen her zaman en güvenlisi değildir. Laravel belgelerinde tooManyAttempts() + increment() (veya eski versiyonlarda hit()) “Manually Incrementing Attempts” başlığı altında gösterilmektedir. Hatalı değil, çoğu kullanım için (kullanıcı başına limitler, kullanıcıların 100 eş zamanlı istekte bulunmaması durumunda) yeterlidir. Ama eğer paralel istismar tehdidi oluşturuyorsanız, belgeler en güvenli seçeneği göstermiyor. Şimdi belgeleri biraz farklı okuyorum: “Buradaki varsayılan kullanıcı kimdir ve tehdit modeli benimle uyumlu mu?”
4. X, güvenlik konularını topladığım yerdir. Bu özel düzeltme, belgeler veya bir güvenlik denetimi yerine, bir 280 karakterlik gönderi yoluyla ulaştı, bu yüzden Laravel kullanıcılarını takip etmek, gerçek uygulamalardaki belirli desen düzeyindeki hataları paylaşan, benim için çoğu güvenlik blogundan daha fazla bilgi sağlıyor. “X’in üstünde yaşadım, bunu böyle düzeltim” şeklinde yazan insanların tweetlerine mürekkep etmeye özen gösterin. Bu, kendi kodunuz için gereken desen eşleştirme türüdür. Teşekkürler, @_newtonjob.
TL;DR
TL;DR
// ❌ Yarışa açık — 100 eş zamanlı istek hepsi geçecektir
if (RateLimiter::tooManyAttempts($key, 5)) {
return 'Too many!';
}
RateLimiter::increment($key);
// ✅ Yarışa güvenli — atomik artış + dönüş değerini kontrol et
if (RateLimiter::increment($key) > 5) {
return 'Too many!';
}
Bir satır kısaltma, bir sorun ortadan kalktı. Eğer Laravel’deki tooManyAttempts() + increment() (veya hit()) çift adım desenini kullanan bir rate-limiting kodunuz varsa, bunu gözden geçirin ve tek çağrılı varyantı yeniden yazın. Özellikle her çağrı size bir şeyler kazandıran bir işlemi koruyorsanız.
Kaynak: Orijinal Makale


