ASP.NET Core Web API'de CORS ve Güvenlik: HTTPS, HSTS, Header'lar ve Secret'lar
CORS'un neden güvenlik duvarı olmadığını curl ile görüyor, politika tanımlayıp preflight isteğini okuyoruz. HTTPS yönlendirme, HSTS'in localhost istisnası, güvenlik header'ları ve user secrets da var.
Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.
Önce Tahmin Et
Son iki yazıda API'ye kimin konuştuğunu ve kimin neye izinli olduğunu öğrettik. Şimdi API'nin dış dünyayla temas ettiği yüzeye bakıyoruz: tarayıcıdan gelen istekler, HTTPS, response header'ları ve gizli anahtarlar.
Senaryo şu. http://localhost:4200 adresinde çalışan bir Angular uygulaması, https://localhost:7080 adresindeki API'den ürün listesini istiyor. API'de hiçbir CORS ayarı yok. Tarayıcı konsolunda kırmızı bir CORS hatası beliriyor. Soru: istek sunucuya ulaştı mı, controller çalıştı mı?
Same-Origin Policy ve CORS
Origin, bir adresin scheme, host ve port üçlüsü. http://localhost:4200 ile https://localhost:7080 farklı origin'ler; port ve scheme farklı. Tarayıcılar same-origin policy denen kuralla, bir sayfadaki JavaScript'in başka bir origin'den gelen response'u okumasını varsayılan olarak engeller. CORS (Cross-Origin Resource Sharing), sunucunun "şu origin'lere güveniyorum" diyerek bu kuralı kontrollü biçimde gevşetmesini sağlayan bir header protokolü.
Tahminin cevabı burada saklı: evet, istek sunucuya ulaştı ve controller çalıştı. Engelleyen sunucu değil, tarayıcı. Tarayıcı response'u aldı, içinde Access-Control-Allow-Origin header'ını bulamadı ve sonucu JavaScript'e vermedi. Bunu kendi gözümle görmek için izin vermediğim bir origin'den, CORS ayarı yapılmış API'ye DELETE isteği gönderdim (-k, curl'ün yerel geliştirme sertifikasını doğrulamaya çalışmamasını sağlıyor):
curl -k -i -X DELETE https://localhost:7080/api/products/2 -H "Origin: https://evil.example.com"
HTTP/1.1 204 No Content
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
Ürün silindi. Response'ta CORS header'ı yok, yani bu isteği bir tarayıcı atsaydı JavaScript sonucu göremeyecekti, ama silme işlemi çoktan olmuştu. Buradan çıkan ders önemli: CORS bir erişim kontrolü değil, tarayıcıya verilen bir talimat. curl, Postman ya da başka bir sunucu CORS'u hiç umursamaz. API'yi korumak authentication ve authorization'ın işi; CORS sadece tarayıcıdaki üçüncü taraf sayfaların kullanıcının adına konuşmasını zorlaştırır.
Bir apartman görevlisi düşün. Görevli, binaya hangi kargo firmasının girebileceğini listesinden kontrol eder. Ama arka bahçeden dolaşan biri için listenin hiçbir anlamı yoktur; dairelerin kapısında yine kilit olmalı.
CORS Politikası Tanımlamak
CORS ASP.NET Core'un içinde geliyor, ek paket gerekmiyor. Politikayı isimlendirip Program.cs'te tanımlıyoruz:
using ProductCatalog.Security;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
// Sadece bilinen frontend adreslerine güveniyoruz
options.AddPolicy("Frontend", policy =>
policy.WithOrigins("https://shop.example.com", "http://localhost:4200")
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Content-Type", "Authorization")
.WithExposedHeaders("X-Total-Count")
.SetPreflightMaxAge(TimeSpan.FromMinutes(10)));
});
builder.Services.AddHsts(options =>
{
options.MaxAge = TimeSpan.FromDays(365);
options.IncludeSubDomains = true;
});
// Response'lardaki "Server: Kestrel" header'ını kaldır
builder.WebHost.ConfigureKestrel(options =>
{
options.AddServerHeader = false;
});
builder.Services.AddControllers();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseSecurityHeaders();
app.UseCors("Frontend");
app.UseAuthorization();
app.MapControllers();
app.Run();
WithOrigins adresleri tam olarak yazmanı ister: scheme ve port dahil, sonda / olmadan. WithExposedHeaders ise sık unutulan bir ayar. Pagination yazısında toplam kayıt sayısını X-Total-Count header'ında döndürmüştük; tarayıcı varsayılan olarak JavaScript'e sadece birkaç temel header'ı gösterir, bu header'ı görmesini istiyorsan açıkça expose etmelisin.
UseCors sırası da önemli: authentication ve authorization middleware'lerinden önce gelmeli. Tarayıcının preflight isteği token taşımaz. Önceki yazının JWT'li ve FallbackPolicy'li API'sine CORS ekleyip UseCors'u UseAuthorization'dan sonraya koyduğumda preflight 401 döndü, tarayıcı da asıl isteği hiç göndermedi. UseCors'u öne alınca aynı preflight 204 döndü. Bu yazıdaki örnekte authentication olmadığı için sadece UseAuthorization görünüyor; ikisini birleştirdiğinde sıra UseCors, UseAuthentication, UseAuthorization olur.
İzinli origin'den gelen bir istekte response şuna dönüşüyor:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Access-Control-Allow-Origin: http://localhost:4200
Access-Control-Expose-Headers: X-Total-Count
Transfer-Encoding: chunked
Vary: Origin
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
X-Total-Count: 2
Vary: Origin, araya giren cache'lere "bu response Origin header'ına göre değişir" der; yoksa bir origin için üretilen cevap başka bir origin'e de dönülebilir.
[EnableCors] ve [DisableCors]
Politikayı tüm uygulamaya değil, controller ya da action bazında uygulamak istersen UseCors()'u parametresiz çağırıp attribute'ları kullanırsın:
[ApiController]
[Route("api/products")]
[EnableCors("Frontend")]
public class ProductsController : ControllerBase
Aynı controller'da bir action'a [DisableCors] koyduğumda, o action için gelen preflight isteği 204 döndü ama hiçbir Access-Control-* header'ı taşımadı; tarayıcı isteği engelledi.
Preflight İsteğini Okumak
Tarayıcı her isteği aynı şekilde göndermez. GET gibi "basit" istekleri doğrudan atar. DELETE, PUT ya da Authorization header'ı taşıyan bir istekten önce ise OPTIONS method'uyla bir preflight isteği gönderip izin ister. Bunu curl ile taklit edebiliriz:
curl -k -i -X OPTIONS https://localhost:7080/api/products/2 -H "Origin: http://localhost:4200" -H "Access-Control-Request-Method: DELETE" -H "Access-Control-Request-Headers: authorization"
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Origin: http://localhost:4200
Access-Control-Max-Age: 600
Vary: Origin
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
Access-Control-Max-Age: 600, tarayıcının bu cevabı 10 dakika boyunca hatırlamasını sağlar; her DELETE öncesinde yeni bir preflight atılmaz. İki ilginç sonuç daha aldım. İzinsiz origin'den gelen preflight da 204 döndü, ama tek bir Access-Control-* header'ı olmadan. Access-Control-Request-Method: PATCH gönderdiğimde ise sunucu yine 204 ve izinli method listesini döndü; listede PATCH olmadığını görüp isteği durduran tarayıcı oldu. Yani preflight'ta da kararı sunucu bildirir, uygulayan tarayıcıdır.
AllowAnyOrigin ile Credentials Bir Arada Olmaz
Geliştirme sırasında "her yerden gelsin" diye şunu yazmak cazip gelir:
options.AddPolicy("Everyone", policy =>
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowCredentials());
Bu politikayı hiçbir yerde kullanmasam bile uygulama açılışta çöktü:
Unhandled exception. System.InvalidOperationException: The CORS protocol does not allow specifying a wildcard (any) origin and credentials at the same time. Configure the CORS policy by listing individual origins if credentials needs to be supported.
CORS spesifikasyonu, cookie gibi kimlik bilgileriyle gelen isteklere Access-Control-Allow-Origin: * cevabını yasaklıyor. ASP.NET Core bunu en baştan yakalıyor. Bazı projelerde bu hatayı aşmak için SetIsOriginAllowed(_ => true) yazıldığını görürsün; bu, yasağı dolanıp her origin'e kimlik bilgisiyle kapı açmak demek. Origin'leri configuration'dan okuyup açıkça listele.
HTTPS Yönlendirme ve HSTS
UseHttpsRedirection, HTTP ile gelen isteği HTTPS adresine yönlendirir:
HTTP/1.1 307 Temporary Redirect
Content-Length: 0
Location: https://localhost:7080/api/products
Ama bir API için yönlendirme tek başına yetmez. İstemci ilk isteği HTTP ile atmışsa token zaten şifresiz yola çıkmıştır. Burada HSTS devreye girer: sunucu Strict-Transport-Security header'ıyla tarayıcıya "bundan sonra bana sadece HTTPS ile gel" der ve tarayıcı belirtilen süre boyunca HTTP denemesini hiç yapmaz.
UseHsts'i template'teki gibi sadece Development dışındaki ortamlarda açtık. Uygulamayı Production ortamında çalıştırınca bir sürprizle karşılaştım: https://localhost:7080 cevabında HSTS header'ı yoktu. Sebep HstsOptions.ExcludedHosts: varsayılan listede localhost, 127.0.0.1 ve [::1] var; HSTS'i yanlışlıkla kendi makinene kilitlememen için. Aynı isteği api.test adıyla attığımda header geldi:
curl -k -i --resolve api.test:7080:127.0.0.1 https://api.test:7080/api/products
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=31536000; includeSubDomains
HTTP üzerinden gelen istekteki 307 cevabında ise HSTS header'ı yoktu; spesifikasyon gereği bu header sadece HTTPS cevaplarında anlam taşır. IncludeSubDomains ve uzun MaxAge değerlerini, bütün alt domain'lerinin HTTPS'e hazır olduğundan emin olduktan sonra aç; tarayıcı bu kararı bir yıl boyunca hatırlar.
Güvenlik Header'ları
API JSON döndürse de birkaç response header'ı ucuz bir koruma katmanı sağlar. Bunları küçük bir middleware ile her cevaba ekliyoruz:
namespace ProductCatalog.Security;
public class SecurityHeadersMiddleware
{
private readonly RequestDelegate _next;
public SecurityHeadersMiddleware(RequestDelegate next)
{
_next = next;
}
public Task InvokeAsync(HttpContext context)
{
var headers = context.Response.Headers;
// Tarayıcı Content-Type'ı tahmin etmeye çalışmasın
headers["X-Content-Type-Options"] = "nosniff";
// Response'lar başka bir sitenin iframe'ine gömülemesin
headers["X-Frame-Options"] = "DENY";
headers["Referrer-Policy"] = "no-referrer";
// JSON dönen bir API'nin script, stil ya da görsel yüklemesine gerek yok
headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'";
return _next(context);
}
}
public static class SecurityHeadersExtensions
{
public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app) =>
app.UseMiddleware<SecurityHeadersMiddleware>();
}
Header'ları _next çağrılmadan önce yazdığımız için, response başladıktan sonra header eklemeye çalışma hatasına düşmüyoruz. nosniff, tarayıcının bir JSON cevabını HTML ya da script sanmasını engeller; frame-ancestors 'none' ve X-Frame-Options: DENY ise clickjacking'e karşı. Yukarıdaki çıktılarda Server: Kestrel satırının da olmadığına dikkat et: AddServerHeader = false ayarı, saldırgana hangi sunucuyu kullandığını söyleyen bu bilgiyi kaldırıyor. Tek başına bir güvenlik önlemi sayılmaz ama bedava.
Secret Yönetimi
JWT imza anahtarı, veritabanı şifresi, üçüncü parti API key'leri repository'ye girmemeli. Geliştirme ortamında bunun için user secrets var:
dotnet user-secrets init
dotnet user-secrets set "Jwt:SigningKey" "secret-from-user-secrets-0123456789abcdef"
İlk komut csproj'a bir UserSecretsId ekliyor, ikincisi değeri projenin dışında, kullanıcı profilindeki bir JSON dosyasına yazıyor. WebApplication.CreateBuilder bu kaynağı sadece Development ortamında okur: aynı uygulamayı Development'ta çalıştırınca Jwt:SigningKey değeri geldi, Production'da null döndü. User secrets bir şifreleme aracı değil, sadece "kaynak kodun dışında tut" aracı.
Production'da değerleri environment variable olarak verirsin. Configuration'daki : ayracının yerine çift alt çizgi yazılır: Jwt__SigningKey. JWT yazısında kısa anahtar hatasını tam olarak bu yolla, Jwt__SigningKey değişkenini değiştirerek denemiştik. Daha büyük sistemlerde Azure Key Vault ya da AWS Secrets Manager gibi bir secret store ve onun configuration provider'ı kullanılır. Hangisini seçersen seç, kural aynı: kod secret'ın nereden geldiğini bilmez, sadece IOptions<JwtOptions> üzerinden okur.
Sık Düşülen Tuzaklar
CORS'u güvenlik duvarı sanmak. Baştaki DELETE örneği gibi, CORS'a takılan istek bile sunucuda çalışmış olabilir.
UseCors'u authentication ve authorization'dan sonra koymak. Preflight istekleri token taşımaz ve 401 ile geri döner; bunu .NET 6'da da .NET 10'da da aynı şekilde gördüm.
Origin'i eksik yazmak. http://localhost:4200/ (sonda / ile) ya da localhost:4200 (scheme olmadan) tarayıcının gönderdiği Origin değeriyle eşleşmez.
HSTS'i ilk denemede bir yıllık MaxAge ile açmak. Tarayıcı bu kararı süre boyunca hatırlar; HTTPS tarafında bir sorun çıkarsa kullanıcıların siteye HTTP ile dönme yolu kalmaz. Önce kısa bir süreyle başla, sorun yoksa uzat. Localhost'u ise varsayılan ExcludedHosts listesi zaten koruyor.
Login endpoint'ini sınırsız bırakmak. Kaba kuvvet denemelerine karşı istek sınırlamak gerekir. Yerleşik rate limiting middleware'i .NET 7 ile geldi; .NET 6'da bunu reverse proxy ya da API gateway katmanında çözersin. Yerleşik middleware'i ilerleyen sürüm yazılarında göreceğiz.
Küçük Bir Deneme
★☆☆ WithOrigins listesini appsettings.json içindeki Cors:AllowedOrigins array'inden oku. Production ve Development için farklı listeler ver.
★★☆ X-Total-Count header'ını WithExposedHeaders'dan çıkar. Basit bir HTML sayfasından fetch ile istek atıp response.headers.get("X-Total-Count") değerinin null döndüğünü tarayıcıda gör.
★★★ SecurityHeadersMiddleware'i options pattern ile yapılandırılabilir hâle getir: hangi header'ların ekleneceği configuration'dan gelsin ve Swagger UI gibi HTML dönen endpoint'ler için farklı bir Content-Security-Policy tanımlanabilsin.
API'nin dış yüzü artık daha sağlam. Ama API büyüdükçe başka bir soru belirecek: ProductDto'nun şeklini değiştirmen gerektiğinde, eski hâline bağlı mobil uygulamayı nasıl kırmayacaksın? Bir sonraki yazıda API versioning'e geçip URL, query string ve header ile sürümlemeyi Asp.Versioning.Mvc paketiyle kuracağız.