Antiforgery Derinlemesine: Cookie Authentication, SPA ve Minimal API'lerde CSRF Koruması
ASP.NET Core'un antiforgery middleware'i isteği reddetmez, sonucu bir feature'a yazar; minimal API'de bu sonucu yalnız form binding uygular, bu yüzden cookie ile çalışan JSON endpoint'leri token'sız istekleri 200 ile kabul eder. Token çiftinin iç yapısını, Angular'ın XSRF-TOKEN pattern'ini, zorunlu doğrulama yapan endpoint filter'ı, login sonrası token yenilemeyi ve DisableAntiforgery'nin bilinçli kullanımını gerçek çıktılarla anlatıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10.0.13 runtime) ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un Angular uygulaması cookie authentication kullanıyor ve sipariş endpoint'i JSON (JavaScript Object Notation) alan bir minimal API (Application Programming Interface). Ekipten biri endpoint'e .WithMetadata(new RequireAntiforgeryTokenAttribute()) ekledi, app.UseAntiforgery() da pipeline'da duruyor. Hiç antiforgery token'ı göndermeyen bir istek gelirse ne olur: 400 mü, 200 mü? İkinci soru: kullanıcı login olmadan önce aldığı geçerli token'ı login'den sonra kullanırsa ne olur?
İlk sorunun cevabı 200 ve bu yazının en önemli bulgusu. CSRF'nin (cross-site request forgery) temelini, cookie flag'leriyle birlikte, OWASP (Open Worldwide Application Security Project) Top 10 listesini anlatan Web Güvenliği serisinin "OWASP A07: Authentication Failures (2)" yazısında anlatmıştık. Burada ASP.NET Core'un antiforgery sisteminin iç işleyişine, minimal API'lerdeki otomatik korumanın sınırına ve Angular ile çalışan bir SPA'da (single-page application) doğru kurulumuna bakıyoruz.
CSRF Ne Zaman Gerçek Risk?
CSRF'nin var olabilmesi için tarayıcının kimlik bilgisini kendiliğinden göndermesi gerekir. Cookie authentication'da öyledir: kullanıcı başka bir sitedeyken o sitenin formu Bookstore'a POST yapar ve tarayıcı bookstore.auth cookie'sini ekler. Access token'ı Authorization: Bearer header'ıyla gönderen bir istemcide ise risk yoktur; başka bir site, kullanıcının tarayıcısına o header'ı ekletemez. Bu yüzden soru "API'm antiforgery kullanmalı mı" değil, "API'm cookie ile mi authenticate oluyor" sorusudur. Angular uygulaması token'ı tarayıcıda tutmak yerine BFF (Backend for Frontend) üzerinden cookie ile çalışıyorsa, ki Authentication ve Authorization serisinin BFF yazısında önerilen mimari bu, CSRF yeniden masaya gelir.
SameSite cookie'leri büyük kısmını çözüyor ama tamamını değil. SameSite=Lax cookie, başka bir siteden gelen POST'ta gönderilmez; fakat "site" origin değildir. shop.bookstore.test ile blog.bookstore.test aynı site sayılır. Blog'da bir XSS (cross-site scripting) açığı ya da başkasına devredilmiş bir alt domain, SameSite'ı tamamen devre dışı bırakır. Üstelik GET ile durum değiştiren bir endpoint, Lax cookie ile top-level navigation'da her zaman korumasızdır. Bu yüzden cookie tabanlı her durum değiştiren endpoint'te ikinci bir kontrol gerekiyor.
Token Çifti: IAntiforgery Nasıl Çalışır
ASP.NET Core double-submit'in kriptografik bir versiyonunu kullanır. IAntiforgery.GetAndStoreTokens iki token üretir. Cookie token'ı, 128 bit'lik rastgele bir güvenlik değeri taşır ve HttpOnly bir cookie'ye yazılır. Request token'ı aynı güvenlik değerini ve kullanıcının kimliğini taşır; sayfaya, forma ya da bir header'a konur. Doğrulama, iki token'ı Data Protection ile açar, güvenlik değerlerinin eşleştiğini ve request token'ındaki kimliğin şu anki kullanıcıyla aynı olduğunu kontrol eder. Kimlik, sub, NameIdentifier ya da UPN (User Principal Name) claim'inden, bunlar yoksa kimliğin bütün claim'lerinden hesaplanan, SHA (Secure Hash Algorithm) ailesinden bir SHA-256 özetidir. Saldırgan cookie'yi okuyamaz, request token'ını da kendi oturumundan getirse kimlik tutmaz. İki token da Data Protection ile şifrelendiği için serinin Data Protection yazısındaki key ring sorunları burada 400 olarak geri döner.
Minimal API'de Otomatik Koruma ve Sınırı
.NET 8'den beri minimal API'lerde form binding yapan bir endpoint ([FromForm], IFormFile, IFormCollection) otomatik olarak antiforgery metadata'sı alır. Bunu, sınırlarını görmek için küçük bir uygulamayla deneyelim:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo("keys"));
builder.Services.AddAntiforgery();
var app = builder.Build();
if (args.Contains("--with-middleware")) app.UseAntiforgery();
app.MapPost("/form", ([FromForm] string title) => Results.Ok(title));
app.MapPost("/json-required", (Book book) => Results.Ok(book)).WithMetadata(new RequireAntiforgeryTokenAttribute());
app.MapPost("/json-feature", (Book book, HttpContext ctx) => Results.Ok(new { book, valid = ctx.Features.Get<IAntiforgeryValidationFeature>()?.IsValid })).WithMetadata(new RequireAntiforgeryTokenAttribute());
app.Run();
record Book(string Title);
== mode: none
HTTP/1.1 500 Internal Server Error
System.InvalidOperationException: Endpoint HTTP: POST /form contains anti-forgery metadata, but a middleware was not found that supports anti-forgery.
Configure your application startup by adding app.UseAntiforgery() in the application startup code. If there are calls to app.UseRouting() and app.UseEndpoints(...), the call to app.UseAntiforgery() must go between them. Calls to app.UseAntiforgery() must be placed after calls to app.UseAuthentication() and app.UseAuthorization().
== mode: --with-middleware
HTTP/1.1 400 Bad Request
{"title":"Dune"} 200
{"book":{"title":"Dune"},"valid":false} 200
Üç sonuç var. AddAntiforgery tek başına middleware'i eklemiyor; .NET 10'da UseAntiforgery() çağrısını unutursan form endpoint'leri her istekte 500 verir. Middleware varken token'sız form isteği 400 alıyor. Ama JSON endpoint'leri, RequireAntiforgeryTokenAttribute taşısalar bile 200 döndü. Sebep, AntiforgeryMiddleware'in isteği reddetmemesi: doğrulamayı yapar, sonucu IAntiforgeryValidationFeature'a yazar ve devam eder. O sonucu okuyup 400'e çeviren şey form binding'dir. JSON body okuyan bir endpoint feature'a hiç bakmaz; üçüncü satırdaki valid: false bunu açıkça gösteriyor. MVC'de (Model-View-Controller) [ValidateAntiForgeryToken] filter'ı bu işi yapar, minimal API'de ise kendin yapmalısın.
Angular ile XSRF-TOKEN Pattern'i
Angular'ın HttpClient'ı, XSRF-TOKEN adlı bir cookie bulursa değerini durum değiştiren isteklere X-XSRF-TOKEN header'ı olarak ekler; adları withXsrfConfiguration ile değiştirebilirsin. Angular bunu GET ve HEAD isteklerine ve URL'si (Uniform Resource Locator) sayfayla aynı origin'de olmayan isteklere eklemez; token başka bir origin'e sızmasın diye. Sunucu tarafında üç parça var: header adını Angular'ınkiyle eşleştirmek, request token'ını JavaScript'in okuyabileceği bir cookie'ye yazmak ve JSON endpoint'lerinde doğrulamayı zorunlu kılan bir endpoint filter:
public static class XsrfCookie
{
public const string Name = "XSRF-TOKEN";
public static void Issue(HttpContext context, IAntiforgery antiforgery)
{
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(context);
context.Response.Cookies.Append(Name, tokens.RequestToken!, new CookieOptions
{
HttpOnly = false,
Secure = context.Request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/"
});
}
}
public sealed class ValidateAntiforgeryFilter(IAntiforgery antiforgery, ILogger<ValidateAntiforgeryFilter> logger)
: IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
try
{
await antiforgery.ValidateRequestAsync(context.HttpContext);
}
catch (AntiforgeryValidationException ex)
{
logger.LogWarning("Antiforgery validation failed for {Method} {Path}: {Reason}",
context.HttpContext.Request.Method, context.HttpContext.Request.Path, ex.Message);
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Invalid antiforgery token");
}
return await next(context);
}
}
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-XSRF-TOKEN";
options.Cookie.Name = "bookstore.af";
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapGet("/antiforgery/token", (HttpContext context, IAntiforgery antiforgery) =>
{
XsrfCookie.Issue(context, antiforgery);
return Results.NoContent();
});
RouteGroupBuilder api = app.MapGroup("/api")
.RequireAuthorization()
.AddEndpointFilter<ValidateAntiforgeryFilter>();
api.MapPost("/orders", (OrderRequest order, ClaimsPrincipal user) =>
Results.Ok(new { orderedBy = user.Identity!.Name, order.BookId }));
app.MapPost("/account/email", ([FromForm] string email, ClaimsPrincipal user) =>
Results.Ok(new { user = user.Identity!.Name, email }))
.RequireAuthorization();
XSRF-TOKEN cookie'sinin HttpOnly olmaması bilinçli: Angular'ın onu okuyabilmesi gerekiyor. Saldırganın sitesi ise bu cookie'yi okuyamaz, çünkü same-origin policy başka bir origin'in cookie'sine JavaScript erişimi vermez. Gerçek güvenlik değeri ise HttpOnly bookstore.af cookie'sinde. Filter'ı route group'a koymak, gruba eklenen her yeni endpoint'in korunmasını garanti ediyor; endpoint bazında hatırlamaya bırakmıyoruz. Filter ValidateRequestAsync'i yalnız durum değiştiren method'lar için çalıştırmaz, GET'te de doğrulama yapar; bu yüzden onu yalnız POST, PUT, PATCH ve DELETE endpoint'lerinin olduğu bir gruba koy ya da method kontrolü ekle.
Yerel uygulamaya Angular'ın yapacağı istekleri curl ile gönderdim. Saldırganın elindeki tek şey tarayıcının otomatik gönderdiği cookie'ler olduğu için "yalnız cookie" isteği, cross-site bir isteğin en küçük kavram kanıtı:
== token
Set-Cookie: bookstore.af=...; path=/; samesite=strict; httponly
Set-Cookie: XSRF-TOKEN=...; path=/; samesite=strict
== order with fresh token
{"orderedBy":"ada","bookId":7} 200
== order cookies only (cross-site attempt)
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Invalid antiforgery token","status":400,"traceId":"00-f7b35e360b7fba4505826b83efb0fd66-5f55a5dca2d7d0a5-00"} 400
== form without token
400
== form with header token
{"user":"ada","email":"ada@bookstore.test"} 200
Form endpoint'i token'ı header'dan da kabul etti, çünkü HeaderName ayarlıyken antiforgery önce header'a, sonra form alanına bakar. Token'sız form isteğinin 400'ü ise gövdesiz geldi ve varsayılan log seviyelerinde hiçbir iz bırakmadı; nedeni ancak Debug seviyesinde Microsoft.AspNetCore.Http.RequestDelegateFactory kategorisinde görünüyor. Filter'ımızdaki Warning log'u bu yüzden değerli.
Login Sonrası Token Yenileme
İkinci tahmin sorusu. Login endpoint'i de antiforgery ile korunuyor (login CSRF'si, saldırganın kurbanı kendi hesabına login etmesidir), yani kullanıcı önce anonim bir token alıyor. Login'den sonra aynı token'la sipariş verirse:
app.MapPost("/login", async (LoginRequest request, HttpContext context, IAntiforgery antiforgery) =>
{
ClaimsPrincipal principal = new(new ClaimsIdentity(
[new Claim(ClaimTypes.Name, request.UserName)], CookieAuthenticationDefaults.AuthenticationScheme));
await context.SignInAsync(principal);
if (request.RefreshToken)
{
// yeni token, yeni kimliğe bağlı olmalı
context.User = principal;
XsrfCookie.Issue(context, antiforgery);
}
return Results.Ok(new { signedIn = request.UserName });
}).AddEndpointFilter<ValidateAntiforgeryFilter>();
== login without refresh
{"signedIn":"ada"} 200
== order with pre-login token
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Invalid antiforgery token","status":400,"traceId":"00-d590b625eff3f500d33dfb26850d388c-33f1f5cfef1fe6fa-00"} 400
warn: Bookstore.Web.ValidateAntiforgeryFilter[0]
Antiforgery validation failed for POST /api/orders: The provided antiforgery token was meant for a different claims-based user than the current user.
warn: Bookstore.Web.ValidateAntiforgeryFilter[0]
Antiforgery validation failed for POST /api/orders: The required antiforgery header value "X-XSRF-TOKEN" is not present.
Anonim token'da kimlik alanı boştu; login'den sonraki istekte kullanıcı Ada. Token yenilenmezse kullanıcı login olduktan hemen sonraki ilk işleminde 400 alır. SignInAsync, HttpContext.User'ı değiştirmez; yeni cookie ancak bir sonraki istekte okunur. Bu yüzden token'ı üretmeden önce context.User = principal atamasını yapıyoruz, yoksa yeni token da anonim kimliğe bağlanır. Aynı şey logout ve kullanıcı değiştirme için geçerli: kimlik değiştiği her yerde token'ı yenile.
DisableAntiforgery'yi Bilinçli Kullanmak
Ödeme sağlayıcısının webhook'u form gönderir ama tarayıcıdan gelmez ve cookie taşımaz; antiforgery token'ı da gönderemez. DisableAntiforgery() bu endpoint'in otomatik metadata'sını kaldırır:
app.MapPost("/webhooks/payments", ([FromForm] string paymentId) => Results.Accepted())
.DisableAntiforgery();
Webhook token'sız 202 döndü. Bu satır güvenlik kontrolünü kaldırmıyor, onu yer değiştiriyor: webhook'un imzasını (HMAC, yani Hash-based Message Authentication Code ile imzalanmış bir header gibi) doğrulamadan DisableAntiforgery yazmak, endpoint'i herkese açmak demek. Kural: DisableAntiforgery yalnız cookie ile authenticate olmayan ve kendi doğrulama mekanizması olan endpoint'lerde. Code review'da bu satırı aramak kolay, her geçtiği yer bir soru olmalı.
.NET 11: Fetch Metadata ile Otomatik Koruma
.NET 11, WebApplication.CreateBuilder ile kurulan uygulamalara varsayılan olarak açık bir CSRF middleware'i ekliyor. Token yerine tarayıcının gönderdiği ve JavaScript'in değiştiremediği Sec-Fetch-Site ve Origin header'larına bakıyor: aynı origin'den gelen istekleri geçiriyor, cross-site ve same-site istekleri reddediyor, güvenilen origin'leri CORS (Cross-Origin Resource Sharing) policy'sinden okuyor. Bu yazıdaki iki sorunun ikisi de orada farklı görünüyor; sonucu yine yalnız form okuyan endpoint'ler uyguluyor. Ayrıntıyı, geçişte kırılabilecek senaryoları ve token sisteminin hâlâ ne zaman gerektiğini serinin son yazısında anlatıyoruz.
Üretimde Dikkat
Antiforgery hatalarını say. Filter'daki Warning log'u, deploy sonrası ani bir artışı (çoğunlukla key ring sorunu ya da token yenilemeyen yeni bir login akışı) yakalamanı sağlar; sabit, düşük bir seviye ise ya eski sekmelerden gelen istekler ya da gerçek denemelerdir. Hata mesajını istemciye vermiyoruz, yalnız log'a yazıyoruz.
XSRF-TOKEN cookie'sini her istekte yeniden üretme. Token'ı uygulama açılırken ve kimlik değiştiğinde üret; her yanıtta yeni token, aynı anda açık iki sekmenin birbirinin token'ını geçersiz kılmasına neden olmaz ama gereksiz Data Protection yükü ve büyük Set-Cookie header'ları demektir.
Birden çok alt domain'de çalışıyorsan cookie'leri Domain vermeden, yani yalnız kendi host'una yaz. Domain=.bookstore.test ile yazılan bir XSRF-TOKEN, kardeş alt domain'deki bir XSS'in token'ı okuyup kullanmasını kolaylaştırır.
Sık Düşülen Tuzaklar
JSON endpoint'inin RequireAntiforgeryTokenAttribute ile korunduğunu sanmak. Metadata, middleware'in doğrulama yapmasını sağlar ama sonucu kimse uygulamaz; yukarıdaki valid: false ile dönen 200 bunun kanıtı.
UseAntiforgery'yi UseAuthentication'dan önce çağırmak. Antiforgery, token'ı kullanıcıya göre doğrular; authentication çalışmadan önce kullanıcı anonimdir ve login olmuş herkes 400 alır. Doğru sıra: authentication, authorization, antiforgery.
GET ile durum değiştirmek. AntiforgeryMiddleware kaynak kodda yalnız POST, PUT ve PATCH isteklerini doğruluyor; GET /orders/42/cancel gibi bir endpoint her korumayı atlar, DELETE de middleware'in doğruladığı method'lar arasında değil. Bizim filter'ımız method'a bakmadan doğruladığı için DELETE'i de kapsıyor.
Bearer token kullanan bir API'ye "olsun" diye antiforgery eklemek. Zarar vermez gibi görünür, ama mobil istemciler ve servisler token alamaz, ekip de ilk sorunda DisableAntiforgery'yi her yere yazar.
Küçük Bir Deneme
★☆☆ ValidateAntiforgeryFilter'ı gruptan kaldır ve sipariş isteğini yalnız cookie'lerle tekrar gönder. Sonra filter yerine IAntiforgeryValidationFeature'ı okuyan küçük bir filter yaz ve iki yaklaşımın hangi durumlarda farklı davrandığını bul.
★★☆ Login endpoint'ine RefreshToken = false ile istek at, ardından Angular'ın yapacağı gibi /antiforgery/token'ı tekrar çağırıp siparişi gönder. Token'ı login yanıtında yenilemek ile ayrı bir çağrıyla yenilemek arasındaki farkı yarış durumu (race condition) açısından düşün.
★★★ Antiforgery cookie'sini __Host- önekiyle (__Host-bookstore.af) yaz ve HTTPS (HTTP Secure) üzerinde çalıştır. Tarayıcının bu öneke koyduğu kuralları ve düz HTTP (Hypertext Transfer Protocol) üzerinde ne olduğunu gözle.
Bu yazıda tarayıcının otomatik davranışını kötüye kullanan istekleri durdurduk. Bir sonraki yazıda bir adım aşağı, Kestrel'e iniyoruz: büyük gövdeler, yavaş istemciler ve sunucuyu meşgul etmeye çalışan bağlantılar için request limitleri.