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

Kestrel ve Request Limitleri: Body Size, Header Timeout, Request Timeout'lar ve Yavaş İstemcilere Karşı Ayarlar

Mert Özen 8 Kas 2026 10 dk 2 görüntülenme
Kestrel ve Request Limitleri: Body Size, Header Timeout, Request Timeout'lar ve Yavaş İstemcilere Karşı Ayarlar

Kestrel varsayılan olarak 28,6 MB gövdeye, 30 saniyelik header süresine ve sınırsız request süresine izin verir; request timeout ise CancellationToken taşınmazsa hiçbir şeyi durdurmaz. Gerçek varsayılanları koddan okuyor, gövde, header, form ve JSON limitlerini, yavaş istemcilere karşı RequestHeadersTimeout ile MinRequestBodyDataRate'i, request timeouts middleware'ini ve Kestrel ile reverse proxy arasındaki iş bölümünü ölçümlerle anlatıyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10.0.13 runtime) ile derlenip denenmiştir; yavaş istemci ölçümleri Python 3 ile yazılmış küçük bir script'le yalnız yerel uygulamaya karşı yapılmıştır.

Önce Tahmin Et

Bookstore'un rapor endpoint'ine .WithRequestTimeout(TimeSpan.FromSeconds(2)) ekledik. Endpoint içinde veritabanı sorgusunu temsil eden 5 saniyelik bir bekleme var, ama CancellationToken'ı sorguya geçirmeyi unuttuk. İstemci ne alır: 2 saniye sonra 504 mü, 5 saniye sonra 200 mü? İkinci soru: Kestrel'in varsayılan ayarlarında, header'larını saniyede bir byte gönderen bir istemci bağlantıyı ne kadar süre açık tutabilir?

Cevaplar sırasıyla "5 saniye sonra 200" ve "30 saniye". İkisi de bu yazının konusu: Kestrel'in ve ASP.NET Core'un bir isteğe ne kadar kaynak ayırdığını sınırlayan ayarlar. Dosya yüklemedeki temel limitleri ASP.NET Core Web API (Application Programming Interface) serisinin "Dosya Yükleme ve İndirme" yazısında, CancellationToken'ı veritabanına kadar taşımayı da aynı serinin "Async/Await ve CancellationToken" yazısında görmüştük. Burada resmin tamamına bakıyoruz: hangi limit nerede, varsayılanı ne, aşılınca istemci ne görüyor ve log'a ne düşüyor.

Varsayılanlar: Kestrel Ne Kadar Toleranslı?

Dokümandaki değerler bazen eski sürümlerden kalıyor; bu yüzden varsayılanları .NET 10'da new KestrelServerLimits(), new FormOptions() ve new RequestTimeoutOptions() object'lerinden doğrudan okudum:

MaxRequestBodySize          30,000,000
MaxRequestHeadersTotalSize  32,768
MaxRequestHeaderCount       100
MaxRequestLineSize          8,192
MaxRequestBufferSize        1,048,576
RequestHeadersTimeout       00:00:30
KeepAliveTimeout            00:02:10
MinRequestBodyDataRate      240 B/s, grace 00:00:05
MinResponseDataRate         240 B/s, grace 00:00:05
MaxConcurrentConnections    null
Http2.MaxStreamsPerConnection 100
Http2.MaxRequestHeaderFieldSize 32,768
Form.ValueCountLimit        1,024
Form.ValueLengthLimit       4,194,304
Form.KeyLengthLimit         2,048
Form.MultipartBodyLengthLimit 134,217,728
Form.MultipartHeadersLengthLimit 16,384
RequestTimeoutOptions.DefaultPolicy null
RequestTimeoutPolicy.TimeoutStatusCode null
JsonSerializerOptions.Web.MaxDepth 0 (0 = 64)

İki satır, Microsoft Learn'deki Kestrel sayfasının eski sürümlerinde yazandan farklı: KeepAliveTimeout 2 dakika değil 130 saniye, HTTP'nin (Hypertext Transfer Protocol) ikinci sürümü HTTP/2 için MaxRequestHeaderFieldSize 8 KB değil 32 KB. Tablonun özeti şu: bir istek, varsayılan olarak yaklaşık 28,6 MB gövde ve 32 KB header taşıyabilir, header'larını 30 saniyede tamamlayabilir, eşzamanlı bağlantı sayısında sınır yoktur ve request timeout hiç yoktur. Bookstore API'si bunların hiçbirine ihtiyaç duymuyor, bu yüzden daraltıyoruz.

Gövde Boyutu: Global Limit ve Endpoint İstisnası

Kural: global limit, uygulamanın en sık gördüğü isteğe göre küçük; büyük gövde bekleyen endpoint'ler açıkça işaretli.

builder.WebHost.ConfigureKestrel(options =>
{
    options.Limits.MaxRequestBodySize = 1 * 1024 * 1024;
    options.Limits.MaxRequestHeadersTotalSize = 16 * 1024;
    options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(5);
    options.Limits.MinRequestBodyDataRate = new(bytesPerSecond: 240, gracePeriod: TimeSpan.FromSeconds(5));
    options.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(30);
});
builder.Services.Configure<FormOptions>(options =>
{
    options.ValueCountLimit = 200;
    options.MultipartBodyLengthLimit = 10 * 1024 * 1024;
});
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.MaxDepth = 16);
builder.Services.AddRequestTimeouts(options =>
{
    options.DefaultPolicy = new RequestTimeoutPolicy { Timeout = TimeSpan.FromSeconds(10) };
    options.AddPolicy("reports", TimeSpan.FromSeconds(2));
});
builder.Services.AddValidation();

RequestHeadersTimeout için 5 saniye demo amaçlı kısa; gerçek bir sistemde 10 ila 15 saniye daha makul. Kitap kapağı yükleme endpoint'i 1 MB'ı aşabilir, bu yüzden ona özel bir limit veriyoruz:

app.MapPost("/covers", async (IFormFile cover, CancellationToken cancellationToken) =>
{
    await using Stream stream = cover.OpenReadStream();
    long length = 0;
    byte[] buffer = new byte[81920];
    int read;
    while ((read = await stream.ReadAsync(buffer, cancellationToken)) > 0)
    {
        length += read;
    }
    return Results.Ok(new { cover.FileName, length });
}).WithMetadata(new RequestSizeLimitAttribute(8 * 1024 * 1024)).DisableAntiforgery();

RequestSizeLimitAttribute MVC'nin (Model-View-Controller) attribute'u ama IRequestSizeLimitMetadata uyguladığı için minimal API'de de çalışıyor; routing middleware'i endpoint seçildiğinde bu değeri isteğin IHttpMaxRequestBodySizeFeature'ına yazar. DisableRequestSizeLimit() da var, ama "sınırsız" neredeyse hiçbir zaman doğru cevap değil. Multipart gövdede ayrıca FormOptions.MultipartBodyLengthLimit devreye girer; endpoint limitini ondan büyük yaparsan küçük olan kazanır. Sonuçlar:

== 1.7 MB body
413
== 3 MB cover
{"fileName":"cover.bin","length":3145728} 200

1,7 MB'lık JSON (JavaScript Object Notation) isteği, Kestrel Content-Length header'ını gördüğü anda, gövdeyi hiç okumadan 0,6 ms'de 413 aldı. Gövde chunked gönderildiğinde de 413 döndü, ama bu kez limit gövde okunurken aşıldı ve endpoint'in parametre binding'i şu exception'ı gördü: BadHttpRequestException: Request body too large. The max request body size is 1048576 bytes. Gövdeyi kendin okuyorsan bu exception'ı yakala; aksi hâlde log'a "unhandled exception" olarak düşer.

Header'lar, Form'lar ve JSON

Gövde limiti toplam boyutu sınırlar, içeriğin şeklini değil. 1 MB içine 60.000 satırlık bir sipariş, binlerce form alanı ya da derin iç içe bir JSON sığar ve her biri farklı bir kaynağı tüketir. Bu yüzden içeriğe de limit koyuyoruz:

app.MapPost("/orders", (CreateOrder order) => Results.Ok(new { items = order.Items.Count }));

app.MapPost("/search", ([FromForm] IFormCollection form) => Results.Ok(new { fields = form.Count }))
    .DisableAntiforgery();

public sealed record CreateOrder([property: MaxLength(50)] List<OrderLine> Items);
public sealed record OrderLine(int BookId, [property: Range(1, 10)] int Quantity);
== 60 items
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Items":["The field Items must be a string or array type with a maximum length of '50'."]},"traceId":"00-28e178ea3714432debfadb0e1475c5ab-a1a7e9cb83a58256-00"} 400
== depth 21
 400
== 300 form fields
 400
== 20 KB header
431

Koleksiyon sınırı .NET 10'un minimal API validation'ıyla geliyor (AddValidation) ve anlamlı bir ProblemDetails dönüyor. Ama dikkat: validation, JSON tamamen deserialize edildikten sonra çalışır; 60 satırı da 60.000 satırı da önce belleğe alırsın. Bu yüzden koleksiyon limiti gövde limitinin yerini tutmaz, onu tamamlar. Derinlik ve form alanı sayısı ise boş gövdeli 400'ler döndü; nedenleri yalnız Debug seviyesinde görünüyor:

dbug: Microsoft.AspNetCore.Http.RequestDelegateFactory[2]
      Failed to read parameter "CreateOrder order" from the request body as JSON.
      System.Text.Json.JsonException: The maximum configured depth of 16 has been exceeded. Cannot read next JSON object. Path: $.x | LineNumber: 0 | BytePositionInLine: 109.
dbug: Microsoft.AspNetCore.Http.RequestDelegateFactory[8]
      Failed to read parameter "IFormCollection form" from the request body as form.
      System.IO.InvalidDataException: Form value count limit 200 exceeded.

JSON derinliğinin varsayılanı 64. Bookstore'un hiçbir modeli 5 seviyeyi geçmiyor; 16 rahat bir pay. Derin iç içe JSON, recursive işleyen kodlarda stack tüketir; limiti düşük tutmak ucuz bir sigorta. 20 KB'lık tek bir header ise 16 KB'lık toplam header limitine takıldı ve Kestrel 431 (Request Header Fields Too Large) döndü. Cookie'leri büyüyen uygulamalarda bu limit beklenmedik yerden patlar; bir kullanıcının cookie'leri 16 KB'ı geçerse siteye hiç giremez.

Yavaş İstemciler: RequestHeadersTimeout ve MinRequestBodyDataRate

Slowloris tipi saldırılar az bant genişliğiyle çok bağlantı açar ve her birini olabildiğince yavaş besler. Davranışı ölçmek için yerel uygulamaya karşı küçük bir Python script'i yazdım: biri header'ları saniyede bir byte gönderiyor, diğeri 10.000 byte'lık bir gövdeyi saniyede 10 byte hızla:

# Yalnız yerel Bookstore uygulamasına karşı: yavaş istemci davranışını ölçer.
import socket, sys, time

def run(mode, host="127.0.0.1", port=19340):
    s = socket.create_connection((host, port))
    s.settimeout(1)
    start = time.monotonic()
    if mode == "headers":
        s.sendall(b"GET /orders HTTP/1.1\r\nHost: localhost\r\n")
        chunks = iter(lambda: b"X", None)
    else:
        s.sendall(b"POST /orders HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 10000\r\n\r\n")
        chunks = iter(lambda: b" " * 10, None)
    while True:
        try:
            data = s.recv(4096)
            if not data:
                print(f"{mode}: connection closed by server after {time.monotonic() - start:.1f}s")
                return
            print(f"{mode}: after {time.monotonic() - start:.1f}s got {data.splitlines()[0].decode()}")
            return
        except socket.timeout:
            try:
                s.sendall(next(chunks))
            except OSError as e:
                print(f"{mode}: send failed after {time.monotonic() - start:.1f}s ({e.__class__.__name__})")
                return

run(sys.argv[1])
headers: after 6.1s got HTTP/1.1 408 Request Timeout
body: after 5.9s got HTTP/1.1 408 Request Timeout

dbug: Microsoft.AspNetCore.Server.Kestrel.BadRequests[17]
      Connection id "0HNPTBL9PQ21L" bad request data: "Reading the request headers timed out."
dbug: Microsoft.AspNetCore.Server.Kestrel.BadRequests[27]
      Connection id "0HNPTBL9PQ21M", Request id "0HNPTBL9PQ21M:00000001": the request timed out because it was not sent by the client at a minimum of 240 bytes/second.

Header timeout'u 5 saniye ayarlıydı, bağlantı 6,1 saniyede kapandı: Kestrel zamanlayıcıları saniyede bir kontrol eder, bu yüzden bir saniyelik sapma normal. Gövdede 5 saniyelik grace period boyunca hız kontrol edilmedi, ardından ilk kontrolde 240 B/s'nin altında kalan istek 408 ile kesildi. İki event de Debug seviyesinde Microsoft.AspNetCore.Server.Kestrel.BadRequests kategorisine düşüyor; production'da bu kategoriyi Information'da tutup sayısını izlemek, yavaş istemci saldırısını görmenin en ucuz yolu. Varsayılan değerlerle aynı istemci header aşamasında 30 saniye bağlantı tutabilirdi. Bu timeout'ların debugger bağlıyken uygulanmadığını unutma; Visual Studio'da test edip "çalışmıyor" demek kolay.

Request Timeouts Middleware ve CancellationToken

Kestrel limitleri istemcinin yavaşlığına karşı; uygulamanın kendi yavaşlığına karşı .NET 8 ile gelen request timeouts middleware'i var. Rapor endpoint'ine 2 saniyelik bir policy bağladık:

app.UseRequestTimeouts();

app.MapGet("/reports/sales", async (ReportService reports, CancellationToken cancellationToken) =>
    Results.Ok(await reports.BuildAsync(cancellationToken)))
    .WithRequestTimeout("reports");

public sealed class ReportService(ILogger<ReportService> logger)
{
    public async Task<object> BuildAsync(CancellationToken cancellationToken)
    {
        try
        {
            // veritabanı sorgusunu temsil ediyor
            await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
            return new { total = 42 };
        }
        catch (OperationCanceledException)
        {
            logger.LogWarning("Sales report cancelled after timeout");
            throw;
        }
    }
}
== report timeout
 504 2.009847s

warn: ReportService[0]
      Sales report cancelled after timeout
warn: Microsoft.AspNetCore.Http.Timeouts.RequestTimeoutsMiddleware[1]
      Timeout exception handled.

Token sorguya kadar taşındığında istek 2 saniyede 504 ile bitti ve sorgu iptal edildi. Şimdi tahmin sorusu: aynı endpoint'i token'ı geçirmeden yazdığımda sonuç {"total":42} 200 5.072117s oldu. Middleware isteği öldürmez; yalnız HttpContext.RequestAborted'ı iptal edilmiş bir token'la değiştirir. Token'ı dinlemeyen kod sonuna kadar çalışır, timeout yokmuş gibi. Varsayılan durum kodu 504 (TimeoutStatusCode null bırakıldığında); yanıt başlamışsa middleware durum kodunu değiştiremez. Bu yüzden timeout'un gerçek etkisi, kodun ne kadar disiplinli şekilde CancellationToken taşıdığına bağlı. DefaultPolicy ile her endpoint'e 10 saniyelik bir üst sınır koyduk; DisableRequestTimeout() ile streaming ve SSE (Server-Sent Events) endpoint'lerini bilinçli olarak dışarıda bırakabilirsin.

Hangi Limit Nerede: Kestrel mi, Reverse Proxy mi?

KonuKestrel / ASP.NET CoreReverse proxy (Nginx)
Gövde boyutuMaxRequestBodySize, endpoint metadata'sıclient_max_body_size (varsayılan 1m)
Yavaş headerRequestHeadersTimeoutclient_header_timeout (varsayılan 60s)
Yavaş gövdeMinRequestBodyDataRateclient_body_timeout (varsayılan 60s)
Form ve JSON şekliFormOptions, MaxDepth, validationYok, içeriği bilmez
Uygulama süresiRequest timeouts middlewareproxy_read_timeout (504 döner, upstream bağlantısını kapatır)

Reverse proxy kaba limitleri erken uygular ve yavaş istemcileri uygulamadan önce karşılar; Nginx gövdeyi varsayılan olarak tamamen buffer'layıp sonra upstream'e gönderdiği için Kestrel yavaş gövdeyi hiç görmez. İçeriğe dair limitler (form alanı sayısı, JSON derinliği, koleksiyon boyutu) ise yalnız uygulamada konabilir. İkisi birbirinin yerine geçmez; aynı limiti iki katmanda tutarlı tutmak (Nginx'te 10 MB, Kestrel'de 1 MB gibi tutarsızlıklar istemciye farklı hata kodları döndürür) Web Server'lar ve Hosting serisinin konusu.

Üretimde Dikkat

En tehlikeli kurulum, limitleri yalnız proxy'ye bırakıp Kestrel'i de doğrudan erişilebilir bırakmak. Container'ın portu yanlışlıkla dışarı açılmışsa ya da aynı ağdaki başka bir servis Kestrel'e doğrudan bağlanabiliyorsa, proxy'deki bütün limitler atlanır. Kestrel'in limitleri bu yüzden "proxy zaten yapıyor" diye varsayılanda bırakılmamalı; kendi başına makul olmalı.

413, 431 ve 408 sayılarını izle. Uygulamaya ulaşan istekler (Content-Length kontrolüyle dönen 413'ler, gövde aşamasında kesilen 408'ler) http.server.request.duration metriğinin durum kodu boyutunda görünür; header aşamasında kesilen istekler ise uygulamaya hiç ulaşmaz, onları Kestrel'in BadRequests log kategorisinden izlersin. 413'ler normalde istemci hatasıdır; ani bir artış ya yeni bir istemci sürümünün daha büyük istekler gönderdiğini ya da birinin limitleri yokladığını gösterir.

Limitleri değiştirirken istemcileri unutma: mobil uygulamanın eski sürümü 2 MB'lık bir istek gönderiyorsa global limiti 1 MB'a çekmek o sürümü kırar. Önce mevcut gövde boyutlarının dağılımını ölç, sonra limiti koy.

Sık Düşülen Tuzaklar

CancellationToken'ı taşımadan request timeout'una güvenmek: yukarıdaki gibi 504 yerine geç bir 200 alırsın ve veritabanı yükü hiç azalmaz.

MaxRequestBodySize'ı null (sınırsız) yapıp dosya yüklemeyi çözmek. Doğrusu, yalnız yükleme endpoint'ine daha yüksek bir limit vermek ve dosyayı belleğe değil stream olarak okumak.

Form limitlerini yalnız MultipartBodyLengthLimit sanmak. ValueCountLimit ve ValueLengthLimit de var; büyük bir form, gövde limitine takılmadan binlerce alanla sunucuyu meşgul edebilir.

Debugger bağlıyken timeout'ları test etmek. RequestHeadersTimeout, MinRequestBodyDataRate ve request timeouts middleware debugger bağlıyken uygulanmaz.

Küçük Bir Deneme

★☆☆ MaxRequestHeadersTotalSize'ı 8 KB'a düşür ve tarayıcıdan büyük bir cookie ile istek at. Kestrel'in yanıtını ve log'unu incele; uygulamada bu durumu kullanıcıya nasıl açıklarsın?

★★☆ Python script'ine --connections 200 parametresi ekle ve aynı anda 200 yavaş bağlantı aç. MaxConcurrentConnections'ı 100 yapıp tekrarla ve normal bir isteğin ne yaşadığını ölç (yalnız yerel uygulamana karşı).

★★★ Rapor endpoint'ini IAsyncEnumerable ile satır satır stream eden bir versiyona çevir. Request timeout'u yanıt başladıktan sonra dolarsa ne oluyor? Durum kodu, bağlantı ve log açısından gözlemle.

Bu yazıyla birlikte istek hacmini, içeriğini ve süresini sınırladık. Bir sonraki yazıda bu sınırların ve önceki yazılardaki bütün güvenlik kararlarının production'da nasıl görüneceğine bakıyoruz: authentication, authorization ve Identity metrikleri, audit trail ve alarmlar.