Mert Özen Her Satırda Daha İleri
Güvenlik ASP.NET Core Güvenliği — #03

Rate Limiting Derinlemesine: Algoritmalar, Partition Key'leri, Chained Limiter'lar ve Çoklu Instance

Mert Özen 8 Kas 2026 12 dk 2 görüntülenme
Rate Limiting Derinlemesine: Algoritmalar, Partition Key'leri, Chained Limiter'lar ve Çoklu Instance

ASP.NET Core'un rate limiter'ları process belleğinde yaşar; iki instance limiti iki katına çıkarır, proxy arkasında ise doğru IP olmadan herkes aynı partition'a düşer. Fixed window, sliding window ve token bucket'ı aynı trafikle karşılaştırıyor; queue sırası, partition key'leri, CreateChained, varsayılan 503 yerine 429 ve Retry-After, paylaşılan policy tuzağı ve kullanıcı adı başına login limitini gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10.0.13 runtime) ve framework'le gelen System.Threading.RateLimiting ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore API'sinde (Application Programming Interface) anonim istekler için IP (Internet Protocol) başına 10 saniyede 5 istek sınırı var. Uygulama iki instance olarak, önünde bir reverse proxy ile çalışıyor. Bir istemci 10 saniye içinde 12 istek gönderiyor; proxy istekleri sırayla iki instance'a dağıtıyor. Kaç istek 200 alır? Şimdi ikinci soru: aynı kurulumda tamamen farklı iki kullanıcı aynı anda 3'er istek gönderiyor. Kaçı 429 alır? İki cevap da "5" değil ve ikisi de yazının sonunda gerçek çıktıyla karşına çıkacak.

OWASP (Open Worldwide Application Security Project) Top 10 listesini anlatan Web Güvenliği serisinin "OWASP A06: Insecure Design" yazısında kupon kodlarına deneme yanılma saldırısını rate limiting ile kesmiştik; ASP.NET Core Web API serisinin .NET 7 yenilikleri yazısı da middleware'in temel kullanımını gösteriyor. Bu yazıda o temelin üstüne çıkıyoruz: algoritmalar arasındaki gerçek fark, partition key seçimi, birden çok limiter'ı zincirlemek, doğru bir 429 yanıtı ve tek instance varsayımının nerede bozulduğu. Dağıtık bir rate limiter'ın nasıl tasarlanacağı ise System Design serisinin "Rate Limiting Tasarımı" yazısının konusu.

Dört Algoritma, Aynı Trafik

System.Threading.RateLimiting dört hazır limiter sunuyor. Farklarını görmek için üçüne aynı trafiği gönderelim: 2 saniyelik pencerede 4 istek sınırı, pencerenin bitmesine 150 ms kala 4 istek, 300 ms sonra 4 istek daha.

TimeSpan window = TimeSpan.FromSeconds(2);

Dictionary<string, Func<RateLimiter>> limiters = new()
{
    ["fixed window"] = () => new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions
    {
        PermitLimit = 4, Window = window, QueueLimit = 0
    }),
    ["sliding window"] = () => new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
    {
        PermitLimit = 4, Window = window, SegmentsPerWindow = 4, QueueLimit = 0
    }),
    ["token bucket"] = () => new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions
    {
        TokenLimit = 4, TokensPerPeriod = 2, ReplenishmentPeriod = TimeSpan.FromSeconds(1), QueueLimit = 0
    }),
};

foreach ((string name, Func<RateLimiter> create) in limiters)
{
    using RateLimiter limiter = create();
    Stopwatch clock = Stopwatch.StartNew();
    await Task.Delay(TimeSpan.FromMilliseconds(1850));
    string before = Burst(limiter, 4);
    await Task.Delay(TimeSpan.FromMilliseconds(300));
    string after = Burst(limiter, 4);
    Console.WriteLine($"{name,-15} t=1.85s {before}   t=2.15s {after}   accepted={(before + after).Count(c => c == '+')}/8");
}
fixed window    t=1.85s ++++   t=2.15s ++++   accepted=8/8
sliding window  t=1.85s ++++   t=2.15s ----   accepted=4/8
token bucket    t=1.85s ++++   t=2.15s ++--   accepted=6/8

Fixed window, pencere sınırında iki katı trafiği kabul etti: 300 ms içinde 8 istek. Pencere sayacı 2. saniyede sıfırlanıyor ve öncesinde ne olduğunu hatırlamıyor. Sliding window pencereyi 4 segmente (her biri 500 ms) bölüyor ve son 2 saniyedeki toplamı sayıyor; 1.85. saniyedeki istekler hâlâ pencerenin içinde olduğu için ikinci grubun hiçbiri geçmedi. Token bucket ise kova doluyken bir burst'e izin veriyor, sonra saniyede 2 token hızında doluyor; 2. saniyede eklenen 2 token iki isteği geçirdi.

Dördüncüsü ConcurrencyLimiter zamandan bağımsız: aynı anda en fazla N istek. Hangisi ne zaman:

LimiterNeyi sınırlarUygun trafik
FixedWindowRateLimiterPencere başına istek sayısıUcuz, kaba sınırlar; sınırda 2 kat burst kabul edilebiliyorsa
SlidingWindowRateLimiterKayan penceredeki istek sayısıLogin, kupon, OTP (one-time password) gibi deneme yanılmaya açık endpoint'ler
TokenBucketRateLimiterOrtalama hız, kontrollü burstAPI key'li istemciler; kısa burst'lere izin verip uzun vadede hızı sabitlemek
ConcurrencyLimiterAynı anda işlenen istekRapor üretimi, dosya export'u gibi pahalı ve uzun süren işler

Queue: Beklet mi, Reddet mi?

Her limiter'ın QueueLimit ve QueueProcessingOrder ayarı var. Queue doluyken gelen isteğin ne olacağını ikinci ayar belirler ve bu, beklediğinden farklı çalışıyor:

foreach (QueueProcessingOrder order in new[] { QueueProcessingOrder.OldestFirst, QueueProcessingOrder.NewestFirst })
{
    using ConcurrencyLimiter limiter = new(new ConcurrencyLimiterOptions
    {
        PermitLimit = 1, QueueLimit = 2, QueueProcessingOrder = order
    });
    RateLimitLease running = await limiter.AcquireAsync();
    Task<RateLimitLease>[] waiting = [limiter.AcquireAsync().AsTask(), limiter.AcquireAsync().AsTask(), limiter.AcquireAsync().AsTask()];
    await Task.Delay(50);
    string before = string.Join(" ", waiting.Select((t, i) => $"#{i + 1}:{(t.IsCompleted ? (t.Result.IsAcquired ? "ok" : "rejected") : "queued")}"));
    running.Dispose();
    await Task.Delay(50);
    string after = string.Join(" ", waiting.Select((t, i) => $"#{i + 1}:{(t.IsCompleted ? (t.Result.IsAcquired ? "ok" : "rejected") : "queued")}"));
    Console.WriteLine($"{order,-11} while busy: {before}   after release: {after}");
}
OldestFirst while busy: #1:queued #2:queued #3:rejected   after release: #1:ok #2:queued #3:rejected
NewestFirst while busy: #1:rejected #2:queued #3:queued   after release: #1:rejected #2:queued #3:ok

OldestFirst'te queue dolunca yeni gelen reddedilir. NewestFirst'te ise en eski bekleyen kuyruktan atılır ve yeni gelen ilk sırada işlenir. Kullanıcının hâlâ beklediği, en yeni isteğe öncelik vermek istediğin etkileşimli senaryolarda NewestFirst mantıklı; ama en eski isteği çoktan timeout'a düşmüş istemciye sonradan 429 dönmek yerine baştan reddetmek de bir seçenek. Rate limit için queue genelde kötü bir fikir: beklettiğin her istek bir bağlantıyı ve belleği tutar, saldırı anında limiter'ı kendi kendine DoS (denial of service) yapan bir yapıya çevirir. Bu yazıdaki bütün limiter'larda QueueLimit = 0; queue'yu yalnız ConcurrencyLimiter ile pahalı işleri sıraya koymak için düşün.

Partition Key: Limit Kimin İçin?

Tek bir FixedWindowRateLimiter herkesin ortak sayacıdır. PartitionedRateLimiter her partition key için ayrı bir limiter tutar; key'i istekten sen üretirsin. Bookstore'da üç tür istemci var: API key'li Pro ve Free istemciler ve anonim istekler. Her API key bir tenant'a ait. Global limiter'ı iki katmanlı kuruyoruz:

options.GlobalLimiter = PartitionedRateLimiter.CreateChained(
    PartitionedRateLimiter.Create<HttpContext, string>(context =>
    {
        ApiClientRegistry registry = context.RequestServices.GetRequiredService<ApiClientRegistry>();
        string tenant = registry.Resolve(context)?.Client.Tenant ?? "anonymous";
        return RateLimitPartition.GetFixedWindowLimiter($"tenant:{tenant}", _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 20, Window = TimeSpan.FromSeconds(10), QueueLimit = 0
        });
    }),
    PartitionedRateLimiter.Create<HttpContext, string>(context =>
    {
        ApiClientRegistry registry = context.RequestServices.GetRequiredService<ApiClientRegistry>();
        if (registry.Resolve(context) is (string key, ApiClient client))
        {
            return RateLimitPartition.GetTokenBucketLimiter($"key:{key}", _ => new TokenBucketRateLimiterOptions
            {
                TokenLimit = client.Tier == Tier.Pro ? 10 : 3,
                TokensPerPeriod = client.Tier == Tier.Pro ? 5 : 1,
                ReplenishmentPeriod = TimeSpan.FromSeconds(1),
                QueueLimit = 0
            });
        }

        string ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
        return RateLimitPartition.GetFixedWindowLimiter($"ip:{ip}", _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 5, Window = TimeSpan.FromSeconds(10), QueueLimit = 0
        });
    }));

CreateChained'de istek, zincirdeki her limiter'dan izin alırsa geçer; biri reddederse 429. İlk katman bir tenant'ın bütün API key'leri toplamda sistemi boğmasın diye var; ikinci katman tek bir key'in ya da IP'nin tenant'ın payını tek başına tüketmesini engelliyor. Partition'ın factory'si (_ => new ...Options) her yeni key için yalnız bir kez çalışır; seçenekleri istemcinin tier'ına göre burada belirliyoruz. Partition key'i string'e gömdüğün önek (tenant:, key:, ip:) bir detay gibi görünür ama iki farklı türün tesadüfen aynı değeri üretmesini engeller.

== anonymous /books x6
200 200 200 200 200 429
== free key x5
200 200 200 429 429

Free tier'daki Alan'ın kovası 3 token'lık, saniyede 1 token doluyor. Anonim istemci 10 saniyede 5 istek hakkına sahip. Partition key'i asla doğrulanmamış bir header'dan doğrudan alma: X-Api-Key değerini kayıtlı key'lerle eşleştirmeden partition yaparsan, saldırgan her isteğe rastgele bir key koyarak her seferinde yeni ve boş bir partition alır, üstelik bellekte binlerce limiter biriktirir. Bu yüzden tanınmayan key'ler IP partition'ına düşüyor.

Endpoint Policy'leri ve DisableRateLimiting

Global limiter her isteğe uygulanır; endpoint'e özel kurallar policy olarak tanımlanır ve global limiter'la birlikte (önce global, sonra endpoint) çalışır:

options.AddPolicy("login", context =>
    RateLimitPartition.GetSlidingWindowLimiter(
        context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
        _ => new SlidingWindowRateLimiterOptions
        {
            PermitLimit = 10, Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 6, QueueLimit = 0
        }));

options.AddFixedWindowLimiter("export", o =>
{
    o.PermitLimit = 2;
    o.Window = TimeSpan.FromSeconds(30);
    o.QueueLimit = 0;
});
app.UseRateLimiter();

app.MapGet("/health", () => "ok").DisableRateLimiting();

app.MapGet("/books", () => new[] { "Dune", "Hyperion" });

app.MapGet("/reports/export", () => Results.Ok(new { file = "orders.csv" }))
    .RequireRateLimiting("export");

DisableRateLimiting hem global limiter'ı hem policy'leri kapatır; health check'in load balancer'ın sık yoklamalarıyla 429 almasını istemezsin. Sekiz ardışık /health isteğinin hepsi 200 döndü. export policy'si ise bir tuzak barındırıyor: AddFixedWindowLimiter("export", ...) partition'sız tek bir limiter oluşturur, yani bütün istemciler aynı sayacı paylaşır:

== export: ada x2 then grace
ada:200 ada:200 grace:429

Ada iki raporu indirdi ve başka bir tenant'taki Grace, hiç istek göndermemiş olmasına rağmen 429 aldı. Bu bazen istenen şeydir (sistem genelinde pahalı bir işi sınırlamak), ama "kullanıcı başına" diye düşünerek yazıldıysa bir başkasının kullanıcılarını kilitlemenin en kolay yolu. Kullanıcı başına sınır istiyorsan AddPolicy ile partition'lı bir limiter yaz.

429, Retry-After ve ProblemDetails

Varsayılan ayarlarla reddedilen istek 429 değil 503 alır. new RateLimiterOptions().RejectionStatusCode değerini yazdırdığımda çıktı 503 oldu. 503 "sunucu hasta" demektir ve birçok istemci kütüphanesi ile load balancer bunu sağlık sinyali olarak yorumlar. İlk iş RejectionStatusCode = 429 yapmak. Sonra OnRejected ile istemciye ne zaman tekrar deneyebileceğini söylüyoruz:

private static async ValueTask WriteRejectionAsync(OnRejectedContext context, CancellationToken cancellationToken)
{
    HttpContext http = context.HttpContext;
    int? retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan wait)
        ? (int)Math.Ceiling(wait.TotalSeconds)
        : null;
    if (retryAfter is not null)
    {
        http.Response.Headers.RetryAfter = retryAfter.Value.ToString(CultureInfo.InvariantCulture);
    }

    http.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Bookstore.RateLimiting")
        .LogWarning("Rate limit hit on {Path} for {Client}", http.Request.Path,
            http.Request.Headers["X-Api-Key"].FirstOrDefault() ?? http.Connection.RemoteIpAddress?.ToString());

    await http.RequestServices.GetRequiredService<IProblemDetailsService>().WriteAsync(new ProblemDetailsContext
    {
        HttpContext = http,
        ProblemDetails =
        {
            Status = StatusCodes.Status429TooManyRequests,
            Title = "Too many requests",
            Detail = retryAfter is null ? "Slow down." : $"Retry after {retryAfter} seconds."
        }
    });
}
HTTP/1.1 429 Too Many Requests
Retry-After: 10
{"title":"Too many requests","status":429,"detail":"Retry after 10 seconds.","traceId":"00-99d2eb600bd6b3e29b7f53dfdb6d77ca-8059cc99d8de7da0-00"}

Dikkatli bak: bu istek, 10 saniyelik pencerenin yaklaşık 1. saniyesinde reddedildi ama Retry-After 10 diyor. .NET 10'daki FixedWindowRateLimiter, bir sonraki pencere sınırını değil pencerenin tamamını raporluyor. .NET 11 bunu düzeltiyor ve RetryAfter metadata'sı gerçek sınırı gösteriyor; serinin son yazısında bu değişikliğe döneceğiz. Token bucket için aynı alan bir sonraki token'a kadar geçen süreyi verir; Free key'deki 429'da Retry-After: 1 geldi. Metadata her limiter'da her zaman yoktur (ConcurrencyLimiter zamanı bilemez), bu yüzden TryGetMetadata kontrolü şart. Zincirli limiter'da da reddeden limiter'ın metadata'sı lease'e taşınır.

OnRejected'te log'luyoruz ama her reddi Warning olarak yazmak, saldırı anında log sistemini ikinci kurban yapar. Production'da reddi metrikle say, log'u örnekle. .NET 8'den beri gelen Microsoft.AspNetCore.RateLimiting meter'ı bunu zaten yapıyor; serinin "Güvenlik Olaylarını Gözlemlemek" yazısında kullanacağız.

Login: IP Başına Limit Yetmez

login policy'si IP başına dakikada 10 deneme veriyor. Credential stuffing yapan bir saldırgan ise tek IP'den değil, binlerce IP'den gelir ve her IP'den birkaç deneme yapar. Bu yüzden login'de ikinci bir sınır, hedef alınan hesap üzerinden gerekiyor. Kullanıcı adı request body'de olduğu için bunu middleware'de değil endpoint içinde, limiter'ı doğrudan kullanarak yapıyoruz:

public sealed class LoginThrottle : IDisposable
{
    private readonly PartitionedRateLimiter<string> _limiter = PartitionedRateLimiter.Create<string, string>(userName =>
        RateLimitPartition.GetFixedWindowLimiter(userName, _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 5, Window = TimeSpan.FromMinutes(15), QueueLimit = 0
        }));

    public RateLimitLease TryAcquire(string userName) =>
        _limiter.AttemptAcquire(userName.Trim().ToUpperInvariant());

    public void Dispose() => _limiter.Dispose();
}
app.MapPost("/login", (LoginRequest request, LoginThrottle throttle) =>
{
    using RateLimitLease lease = throttle.TryAcquire(request.UserName);
    if (!lease.IsAcquired)
    {
        return Results.Problem(statusCode: StatusCodes.Status429TooManyRequests, title: "Too many login attempts");
    }

    return request.Password == "dev-only-password" ? Results.Ok() : Results.Unauthorized();
}).RequireRateLimiting("login");

Yerel uygulamada yedi farklı IP adresinden (X-Forwarded-For ile simüle edilmiş belgeleme aralığı adresleri) Ada'nın hesabına yanlış parolalar gönderdim; altıncı denemede kullanıcı adını " ADA" olarak yazdım:

== login for ada from 7 different IPs
401 401 401 401 401 429 429

IP policy'si hiç devreye girmedi, çünkü her IP yalnız bir deneme yaptı. Kullanıcı adı başına sınır beşinci denemeden sonra durdurdu; Trim().ToUpperInvariant() normalizasyonu olmasaydı " ADA" yepyeni bir partition açacaktı. Bu limiter'ın bir bedeli var: saldırgan Ada'nın hesabını 15 dakikalığına kilitleyebilir. ASP.NET Core Identity'nin lockout özelliği aynı takası yapıyor; Web Güvenliği serisinin "OWASP A07: Authentication Failures (1)" yazısında anlattığımız MFA (multi-factor authentication) ve CAPTCHA (Completely Automated Public Turing test to tell Computers and Humans Apart) gibi önlemler bu limiter'ı tamamlar, onun yerine geçmez.

Üretimde Dikkat: Çok Instance ve Proxy

Şimdi açılıştaki iki soru. Birincisi: iki instance, IP başına 10 saniyede 5 istek, istekler sırayla dağıtılıyor:

200 200 200 200 200 200 200 200 200 200 429 429

Limit 5 değil 10 oldu. Bu limiter'lar process belleğinde yaşıyor; her instance kendi sayacını tutuyor. N instance, limiti N katına çıkarır ve autoscaling ile N değişiyorsa limitin kendisi de değişir. Seçeneklerin: limiti instance sayısına bölerek yaklaşık bir değer kullanmak (sticky session yoksa trafik dengesiz dağılır), ortak sayacı Redis gibi bir store'da tutan bir limiter kullanmak (her istekte bir network çağrısı) ya da kaba limiti API gateway'e veya Nginx'e taşıyıp uygulamada yalnız iş kuralına bağlı limitleri (login, kupon) tutmak. Bu takasların ayrıntısı System Design serisinin rate limiting yazısında.

İkinci soru: proxy arkasında RemoteIpAddress istemcinin değil proxy'nin adresidir. İki farklı istemciyi X-Forwarded-For ile simüle ettim:

== BehindProxy=false
200 200 200 200 200 | second client: 429
== BehindProxy=true
200 200 200 200 200 | second client: 200

Forwarded headers işlenmezken birinci istemci limiti doldurdu ve hiç istek göndermemiş ikinci istemci 429 aldı; bütün dünya tek bir IP gibi görünüyor. UseForwardedHeaders eklendiğinde ikinci istemci kendi partition'ını aldı. Bu satırın tek başına yeterli olmadığını, hangi proxy'lere güvenileceğinin (KnownProxies, KnownIPNetworks) doğru ayarlanması gerektiğini Web Server'lar ve Hosting serisinin üçüncü yazısında ayrıntılı anlatıyoruz. Yanlış ayarlanmış bir güven listesi, saldırganın her isteğe farklı bir X-Forwarded-For yazarak IP limitini tamamen atlatması demek.

Limitleri baştan tahminle koyma. Önce limiter'ı çok yüksek bir değerle devreye al, 429 yerine yalnız log'la ya da metrik üret, bir iki hafta gerçek dağılımı izle; sonra en yoğun meşru istemcinin birkaç katı bir limitle başla.

Sık Düşülen Tuzaklar

RejectionStatusCode'u ayarlamamak: istemciler 503 görür, retry politikaları ve load balancer'lar bunu sunucu hatası sayar.

UseRateLimiter'ı UseRouting'den önce çağırmak. Endpoint'e özel policy'ler ve DisableRateLimiting endpoint metadata'sından okunur; middleware endpoint seçilmeden çalışırsa yalnız global limiter uygulanır. WebApplication routing'i en başa eklediği için minimal API'de bu genelde sorun olmaz, ama UseRouting'i elle çağırıyorsan sırayı kontrol et.

Partition key'i doğrulanmamış girdiden üretmek (rastgele API key, rastgele kullanıcı adı): her değer yeni bir limiter, sınırsız bellek.

Login'i yalnız IP ile sınırlamak ya da yalnız kullanıcı adıyla sınırlamak. Biri dağıtık saldırıyı, diğeri tek IP'den çok hesabı deneyen saldırıyı kaçırır; ikisi birlikte gerekir.

Küçük Bir Deneme

★☆☆ Algoritma karşılaştırmasına SegmentsPerWindow = 2 olan bir sliding window ekle. İkinci grubun kaç isteği geçiyor ve neden?

★★☆ Global zincire üçüncü bir katman olarak ConcurrencyLimiter ekle (bütün sistem için en fazla 50 eşzamanlı istek) ve ConcurrencyLimiter'ın reddettiği isteklerde Retry-After header'ının neden gelmediğini log'la doğrula.

★★★ LoginThrottle'ı başarılı login'de sayacı sıfırlayacak şekilde değiştir. RateLimiter API'si sıfırlamaya izin vermiyor; partition key'e bir "nesil" sayacı ekleyerek ya da kendi sayacını yazarak çöz ve iki yaklaşımın bellek davranışını karşılaştır.

Rate limiting istek hacmini sınırladı. Bir sonraki yazıda yanıtın kendisine dönüyoruz: security header'ları ve nonce'lu CSP'yi (Content Security Policy) ASP.NET Core'dan üretmek.