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

.NET 11 ile ASP.NET Core Güvenliği: Fetch Metadata Tabanlı Otomatik CSRF Koruması, Device Bound Session Credentials ve TLS Channel Binding

Mert Özen 8 Kas 2026 10 dk 1 görüntülenme
.NET 11 ile ASP.NET Core Güvenliği: Fetch Metadata Tabanlı Otomatik CSRF Koruması, Device Bound Session Credentials ve TLS Channel Binding

.NET 11, WebApplication.CreateBuilder ile kurulan uygulamalara Sec-Fetch-Site ve Origin header'larına bakan, varsayılan açık bir CSRF koruması ekliyor; ama kararı yalnız form okuyan endpoint'ler uyguluyor ve DisableAntiforgery onu da kapatıyor. Karar tablosunu, CORS ile güvenilen origin'leri, aynı kuralları .NET 10'da uygulayan bir middleware'i, deneysel DBSC'yi, TLS channel binding'i, authorization metadata birleşmesini ve geçiş kontrol listesini anlatıyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10.0.13 runtime) ile derlenip denenmiştir. .NET 11 (ASP.NET Core 11) özellikleri, 10 Kasım 2026'daki GA (general availability) sürümünden önce, NuGet'teki 11.0.0-rc.3 paketleri ve Microsoft Learn'deki .NET 11 dokümanları üzerinden anlatılmıştır; .NET 11 kodu derlenmediği için verilmemiştir.

Önce Tahmin Et

Bookstore'un yönetim paneli https://admin.bookstore.test adresinde ayrı bir uygulama ve cookie ile authenticate olan kullanıcıların tarayıcısından https://shop.bookstore.test'teki API'ye (Application Programming Interface) form post ediyor. .NET 10'da UseAntiforgery çağırmıyoruz, form endpoint'lerinde DisableAntiforgery var. Uygulamayı .NET 11'e taşıyıp tek satır kod değiştirmeden deploy edersen panelin form'ları çalışmaya devam eder mi? Peki aynı paneldeki JSON (JavaScript Object Notation) gönderen fetch çağrıları?

Bu, serinin son yazısı ve konusu .NET 11'in ASP.NET Core güvenliğine getirdikleri. En büyük değişiklik, WebApplication.CreateBuilder ile kurulan her uygulamada varsayılan olarak açık gelen, token yerine tarayıcının Fetch Metadata header'larına bakan bir CSRF (cross-site request forgery) koruması. Yanına deneysel Device Bound Session Credentials (DBSC) desteği, authorization metadata'sının MVC (Model-View-Controller), SignalR ve Blazor'da birleşmesi ve TLS (Transport Layer Security) channel binding geliyor. .NET 11 GA'den iki gün önce yazıldığı için burada anlatılan her ayrıntıyı GA sürümünün release notes'uyla karşılaştırmanı öneririm.

Otomatik CSRF Koruması: Karar Tablosu

Antiforgery yazısında token çiftini ve tarayıcının otomatik cookie gönderiminin nasıl kötüye kullanıldığını gördük. Modern tarayıcılar her isteğe Sec-Fetch-Site header'ını ekliyor ve bu header (Origin gibi) JavaScript tarafından değiştirilemeyen "forbidden header"lardan. .NET 11'in middleware'i kararını bu iki header'dan veriyor. Microsoft Learn'deki .NET 11 sürümlü "Prevent CSRF attacks" sayfasındaki kurallar, sırayla ve ilk eşleşen kazanır:

#KoşulKarar
1Method GET, HEAD, OPTIONS ya da TRACEİzin
2Sec-Fetch-Site: same-origin ya da noneİzin
3Origin var ve endpoint'in CORS (Cross-Origin Resource Sharing) policy'si onu güveniyor ve AllowCredentials() çağrılmışİzin
4Sec-Fetch-Site: same-site ya da cross-siteRed
5Sec-Fetch-Site yok ama Origin varYalnız Origin, isteğin scheme://host[:port] değeriyle aynıysa izin
6İkisi de yokİzin (tarayıcı olmayan istemci sayılır)

Dördüncü kural, SameSite cookie'lerin kapatamadığı açığı kapatıyor: blog.bookstore.test'ten gelen istek same-site'tır ve reddedilir. Altıncı kural ise curl, mobil uygulama ve servisler gibi tarayıcı olmayan istemcileri bozmamak için var; zaten bu istemciler cookie'yi kendiliğinden göndermez. Beşinci kural Fetch Metadata'yı desteklemeyen eski tarayıcılar için bir yedek.

Middleware, authentication ve authorization'dan sonra çalışıyor ve kararını ICsrfProtection interface'inden alıyor; varsayılan implementasyon TryAddSingleton ile kayıtlı olduğu için kendi kuralınla değiştirebiliyorsun. Bir endpoint'i dışarıda bırakmak için .NET 10'daki gibi DisableAntiforgery() ya da MVC'de [IgnoreAntiforgeryToken] kullanılıyor. Bütünüyle kapatmak için DisableCsrfProtection configuration key'i (ASPNETCORE_DisableCsrfProtection=true) var; ama doküman, bu durumda token tabanlı antiforgery'ye güvenen endpoint'lerin exception almaması için app.UseAntiforgery()'yi çağırman gerektiği konusunda uyarıyor.

Güvenilen Origin'ler: CORS ile

Açılıştaki sorunun ilk yarısının cevabı şaşırtıcı: form'lar çalışmaya devam eder. DisableAntiforgery() yalnız token sistemini değil yeni CSRF middleware'ini de kapatır; yani panelin post ettiği endpoint'ler .NET 11'de de korumasız kalır. Doğru geçiş, opt-out'u kaldırıp panelin origin'ini açıkça güvenmek; aksi hâlde same-site olan bu istekler dördüncü kurala takılır. Ayrı bir CSRF izin listesi yok; middleware, endpoint için çözülen CORS policy'sini kullanıyor: önce [EnableCors("ad")] ya da .RequireCors("ad"), sonra varsayılan policy. Güvenmek için origin'i bir policy'ye eklemek yetmiyor, AllowCredentials() da şart. AllowAnyOrigin bir güven sinyali sayılmıyor; [DisableCors] CSRF korumasını kapatmıyor. SetIsOriginAllowed ile yazılmış bir predicate ise güveniliyor; joker alt domain kuralları yazıyorsan eşleşebilecek her origin'in senin kontrolünde olduğundan emin ol.

Deferred Validation: Kim 400 Alır?

Burada serinin antiforgery yazısındaki bulgu tekrar karşımıza çıkıyor. .NET 11'in middleware'i de isteği kendisi reddetmiyor: kararı IAntiforgeryValidationFeature'a yazıyor ve devam ediyor. Kararı 400'e çevirenler, dokümana göre antiforgery ile korunan MVC action'ları, form binding yapan minimal API endpoint'leri, Blazor SSR (server-side rendering) ve formu doğrudan okuyan kod. JSON body okuyan bir endpoint, kararı Red olsa bile normal çalışıyor. Yani açılıştaki sorunun ikinci yarısının cevabı da "çalışır": admin panelinin JSON fetch çağrıları .NET 11'de de geçiyor, ama cookie ile çalışan bir JSON API'si .NET 11'e geçince kendiliğinden korunmuş olmuyor. İki akış da çalışıyor, ikisi de korumasız. Antiforgery yazısındaki ValidateAntiforgeryFilter ya da aşağıdaki gibi kararı uygulayan bir katman hâlâ gerekli.

Token sistemiyle ilişki de net tanımlı: app.UseAntiforgery() çağrıldığında token middleware'i CSRF kararını siler ve kendi sonucunu yazar. Geçerli bir token, Fetch Metadata'ya göre reddedilmiş bir isteği kurtarır; eksik ya da geçersiz bir token ise izin verilmiş bir isteği düşürür. Doküman, Fetch Metadata göndermeyen tarayıcıları desteklemen ya da IAntiforgeryAdditionalDataProvider kullanman veya uyumluluk için bağımsız bir token katmanı gerekiyorsa token sistemini tutmanı söylüyor.

.NET 10'da Aynı Kuralları Uygulamak

.NET 10 LTS (long-term support) sürümü ve birçok ekip bir süre daha orada kalacak. Karar tablosu basit olduğu için aynı kuralları .NET 10'da küçük bir middleware ile uygulayabilirsin. Bu, .NET 11'in kodu değil; dokümante edilmiş kuralları izleyen kendi implementasyonumuz. Bir farkı bilerek koyduk: kararı yalnız form okuyanlara bırakmıyor, durum değiştiren her isteğe 403 ile uyguluyor; JSON API'leri de korunuyor.

// .NET 10 için: .NET 11'in dokümante edilmiş karar tablosunu taklit eden kendi middleware'imiz
public sealed class FetchMetadataCsrfMiddleware(
    RequestDelegate next, ICorsPolicyProvider corsPolicies, ILogger<FetchMetadataCsrfMiddleware> logger)
{
    public async Task InvokeAsync(HttpContext context)
    {
        string? verdict = await EvaluateAsync(context);
        if (verdict is null)
        {
            await next(context);
            return;
        }

        logger.LogWarning("Cross-site request blocked: {Method} {Path} ({Reason})",
            context.Request.Method, context.Request.Path, verdict);
        context.Response.StatusCode = StatusCodes.Status403Forbidden;
    }

    private async Task<string?> EvaluateAsync(HttpContext context)
    {
        string method = context.Request.Method;
        if (HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method) || HttpMethods.IsTrace(method))
        {
            return null;
        }

        if (context.GetEndpoint()?.Metadata.GetMetadata<IAntiforgeryMetadata>() is { RequiresValidation: false })
        {
            return null;
        }

        string? site = context.Request.Headers["Sec-Fetch-Site"];
        string? origin = context.Request.Headers.Origin;
        if (site is "same-origin" or "none")
        {
            return null;
        }

        if (origin is not null && await IsTrustedAsync(context, origin))
        {
            return null;
        }

        if (site is "same-site" or "cross-site")
        {
            return $"Sec-Fetch-Site: {site}";
        }

        if (origin is not null)
        {
            string self = $"{context.Request.Scheme}://{context.Request.Host}";
            return string.Equals(origin, self, StringComparison.OrdinalIgnoreCase) ? null : $"Origin: {origin}";
        }

        return null;
    }

    private async Task<bool> IsTrustedAsync(HttpContext context, string origin)
    {
        CorsPolicy? policy = await corsPolicies.GetPolicyAsync(context, policyName: null);
        return policy is { SupportsCredentials: true, AllowAnyOrigin: false } && policy.IsOriginAllowed(origin);
    }
}
builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
    .WithOrigins("https://admin.bookstore.test")
    .AllowAnyHeader()
    .AllowAnyMethod()
    .AllowCredentials()));

var app = builder.Build();

app.UseCors();
app.UseMiddleware<FetchMetadataCsrfMiddleware>();

app.MapPost("/orders", (OrderRequest order) => Results.Ok(new { order.BookId }));
app.MapPost("/webhooks/payments", () => Results.Accepted()).DisableAntiforgery();

Tarayıcının göndereceği header'ları curl ile taklit ederek her kuralı yerel uygulamaya karşı denedim:

same-origin                                                200
cross-site, Origin https://evil.example                    403
same-site, Origin https://blog.bookstore.test              403
same-site, Origin https://admin.bookstore.test (CORS)      200
no Sec-Fetch-Site, Origin = self                           200
no Sec-Fetch-Site, Origin https://evil.example             403
no Sec-Fetch-Site, no Origin (curl, server-to-server)      200
webhook, cross-site (DisableAntiforgery)                   202

warn: Bookstore.Web.FetchMetadataCsrfMiddleware[0]
      Cross-site request blocked: POST /orders (Sec-Fetch-Site: same-site)

Bu basit implementasyon yalnız varsayılan CORS policy'sine bakıyor; endpoint'e özel RequireCors policy'lerini de çözmek istersen ICorsPolicyProvider'a endpoint metadata'sındaki policy adını vermen gerekir. .NET 11'e geçtiğinde bu middleware'i kaldırıp framework'ünkine dönebilirsin; ama JSON endpoint'lerini korumak için kararı uygulayan bir filter'ı tutmayı unutma.

Device Bound Session Credentials

Cookie çalan bir zararlı yazılım, cookie'nin ömrü boyunca kullanıcı gibi davranabilir. W3C'nin (World Wide Web Consortium) taslak DBSC spesifikasyonu, oturumun yenilenmesini tarayıcının cihazda tuttuğu bir private key'e bağlıyor: uygulama kısa ömürlü bir session cookie verir, tarayıcı onu yenilemek için o key ile imzalanmış bir kanıt sunmak zorundadır. Kopyalanan cookie süresi dolana kadar kullanılabilir, ama key'e sahip olmayan saldırgan oturumu uzatamaz.

.NET 11, Microsoft.AspNetCore.Authentication.DeviceBoundSessions paketinde deneysel bir sunucu implementasyonu getiriyor. Paket var olan bir cookie scheme'inin üstüne oturuyor, kayıt ve yenileme endpoint'lerini, path'e özel bir refresh cookie'sini ve kısa ömürlü session cookie'sini yönetiyor; kısa ömrü ShortLivedCookieExpiration seçeneği belirliyor. Kurulum AddCookie(...) zincirine AddDeviceBoundSession(...) eklemekten ibaret. Paket NuGet'te 0.11.0-rc.1 sürümüyle duruyor ve dokümana göre spesifikasyon olgunlaşana kadar, .NET 11 boyunca prerelease kalacak. Tarayıcı tarafı da deneysel; Chrome'un DBSC sayfası güncel durumu anlatıyor. Bu yüzden DBSC bugün bir "ek katman": desteklemeyen tarayıcıda oturum normal cookie gibi çalışır, yani kısa cookie ömrünün kullanıcı deneyimine etkisini ölçmeden production'a koyma. Oturum yönetiminin geri kalanı Authentication ve Authorization serisinin cookie authentication yazısında.

Authorization Metadata, TLS Channel Binding ve Diğerleri

Serinin ilk yazısında IAuthorizationRequirementData attribute'larının .NET 10'da yalnız middleware tarafından, yani minimal API'lerde ve endpoint routing'deki action'larda okunduğunu söylemiştik. .NET 11'de AuthorizationPolicy.CombineAsync(IAuthorizationPolicyProvider, IEnumerable<object>) adında yeni bir overload var ve MVC filter'ları, SignalR hub method'ları ve Blazor'ın AuthorizeView ile AuthorizeRouteView component'leri aynı birleştirme kodunu kullanıyor. Hem IAuthorizeData hem IAuthorizationRequirementData uygulayan bir attribute (bizim RequirePermissionAttribute gibi) karara bir kez katılıyor. EnableEndpointRouting = false ile çalışan eski MVC yolu değişmiyor.

Windows ortamında Kerberos ya da NTLM (NT LAN (local area network) Manager) kullanan Negotiate authentication, Kestrel'de HTTPS (HTTP Secure) bağlantılarında artık TLS endpoint channel binding token'ını kullanıyor; configuration gerekmiyor. Channel binding, kimlik doğrulamayı o TLS bağlantısına bağlayarak relay saldırılarını zorlaştırıyor. Kendi protokolünde aynı token'a ihtiyacın varsa ITlsConnectionFeature üzerinde yeni bir TryGetChannelBindingBytes method'u var; HTTP.sys'te davranışı HttpAuthenticationHardeningLevel (Legacy, varsayılan Medium, Strict) belirliyor.

Daha küçük ama seride karşılaştığımız iki düzeltme var. Kestrel yazısındaki RequestHeadersTimeout, .NET 11'de HTTP/2 ve HTTP/3 trailer header'larına da uygulanıyor. Rate limiting yazısında Retry-After'ın hatalı olduğunu görmüştük; .NET 10'daki davranış şu:

using FixedWindowRateLimiter limiter = new(new FixedWindowRateLimiterOptions
{
    PermitLimit = 1, Window = TimeSpan.FromSeconds(10), QueueLimit = 0
});
Stopwatch clock = Stopwatch.StartNew();
using RateLimitLease first = limiter.AttemptAcquire();

foreach (int second in new[] { 1, 4, 7 })
{
    await Task.Delay(TimeSpan.FromSeconds(second) - clock.Elapsed);
    using RateLimitLease lease = limiter.AttemptAcquire();
    lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter);
    Console.WriteLine($"t={clock.Elapsed.TotalSeconds:F1}s acquired={lease.IsAcquired} RetryAfter={retryAfter.TotalSeconds:F1}s, next window in {10 - clock.Elapsed.TotalSeconds:F1}s");
}
t=1.0s acquired=False RetryAfter=10.0s, next window in 9.0s
t=4.0s acquired=False RetryAfter=10.0s, next window in 6.0s
t=7.0s acquired=False RetryAfter=10.0s, next window in 3.0s

.NET 10, pencerenin neresinde olursan ol 10 saniye bekle diyor. .NET 11 release notes'una göre FixedWindowRateLimiter artık bir sonraki pencere sınırını raporluyor; OnRejected'te bu metadata'yı Retry-After'a taşıyan uygulamalar kod değişikliği olmadan doğru değeri döndürecek. Aynı sürümde TokenBucketRateLimiter'ın kısmi token dolumu ve CreateChained limiter'ının iç limiter'lardan idle süresini aktarması da düzeltiliyor.

Üretimde Dikkat: Geçiş Kontrol Listesi

.NET 10'dan 11'e geçerken bu yazının ve dokümanın önerdiği adımlar:

  • Başka bir origin'den form post eden her akışı bul (yönetim paneli, ödeme sağlayıcısının dönüşü, eski bir portal). Güvendiğin origin'i WithOrigins(...) ve AllowCredentials() ile bir CORS policy'sine ekle.
  • DisableAntiforgery() ve [IgnoreAntiforgeryToken]'ı yalnız cookie kullanmayan, kendi imza doğrulaması olan endpoint'lere (webhook, bearer token'lı API) uygula.
  • Fetch Metadata göndermeyen tarayıcıları destekliyorsan ya da bağımsız bir token katmanı istiyorsan app.UseAntiforgery()'yi tut.
  • Blazor SSR uygulamalarında UseAntiforgery() çağırmadan çalışıyorduysan form'larının artık token üretmediğini kontrol et; doküman bunu breaking change olarak listeliyor.
  • Test sırasında Microsoft.AspNetCore.Antiforgery.CsrfProtectionMiddleware kategorisini Debug'a çekip CsrfValidationFailed event'lerini izle; integration test'lerin HttpClient ile gönderdiği istekler bu header'ları taşımadığı için altıncı kurala düşer ve sorunu göstermez.
  • GET ile durum değiştiren endpoint bırakma; birinci kural onları her zaman geçirir.

Duende'nin Eylül 2026 tarihli yazısı, IdentityServer'ın iframe tabanlı front-channel logout bildirimlerinin bu korumayla düşebileceği uyarısını yapıyor. Karar tablosuna göre iframe'lerin yaptığı GET istekleri birinci kuraldan geçer; asıl risk cross-site POST yapan protokol akışlarında. Kimlik sağlayıcı entegrasyonunu GA sürümüyle, gerçek bir tarayıcıda uçtan uca test et.

Sık Düşülen Tuzaklar

.NET 11'e geçince cookie ile çalışan JSON API'sinin korunduğunu sanmak. Karar yalnız form okuyanlarda uygulanıyor; antiforgery yazısındaki filter hâlâ gerekli.

CSRF kararını integration test'lerle doğrulamaya çalışmak. WebApplicationFactory'nin istemcisi Sec-Fetch-Site ve Origin göndermez, istek "tarayıcı olmayan istemci" sayılıp geçer. Testte bu header'ları elle ekle.

Güvenilen origin'i AllowAnyOrigin ya da AllowCredentials olmadan eklemek. İkisi de CSRF için güven sinyali değil; panel 400 almaya devam eder ve ekip korumayı tamamen kapatmaya yönelir.

DBSC'yi bugün bir zorunluluk gibi tasarlamak. Paket ve tarayıcı desteği deneysel; desteklemeyen tarayıcılarda oturum klasik cookie davranışına döner.

Küçük Bir Deneme

★☆☆ FetchMetadataCsrfMiddleware'i bir route group'a özel hâle getir: yalnız /api altındaki endpoint'lerde çalışsın. Sonra karar tablosunun her satırı için bir integration test yaz; header'ları testte elle ekle.

★★☆ Middleware'i endpoint'e özel CORS policy'lerini de çözecek şekilde genişlet: endpoint metadata'sından IEnableCorsAttribute'u oku ve policy adını ICorsPolicyProvider'a geçir.

★★★ .NET 11 GA kurulduğunda Bookstore'u net11.0'a taşı, kendi middleware'ini kaldır ve aynı curl matrisini framework'ün korumasına karşı çalıştır. Hangi satırların farklı sonuç verdiğini, özellikle JSON endpoint'inde ne olduğunu not et.

Seriyi burada tamamlıyoruz. Authorization'ın iç yapısından Data Protection'a, rate limiting'den header'lara, antiforgery'den Kestrel limitlerine ve gözleme kadar, framework'ün güvenlik araçlarının hangi kararı nerede verdiğini ve production'da nasıl ayarlanacağını gördük. Kullanıcıyı kimin doğruladığı ve ona hangi yetkilerin verileceği ise Authentication ve Authorization serisinin konusu; tarayıcı tarafındaki CSP (Content Security Policy) ve Trusted Types ayrıntısı da Frontend Güvenliği serisinde.