Security Header'ları ve Nonce'lu CSP'yi ASP.NET Core'dan Üretmek: HSTS Preload, COOP/COEP/CORP, Permissions-Policy ve Raporlama Endpoint'i
Nonce'lu CSP her istekte değişir ve yanıtın türüne bağlıdır, bu yüzden Nginx'te değil uygulamada üretilmeli ve HTML hiçbir katmanda cache'lenmemeli. ASP.NET Core'da API ve HTML için ayrı header setleri üreten tek bir middleware, Angular index.html'ine nonce enjeksiyonu, Report-Only ve boyut ile rate limit korumalı rapor endpoint'i, HSTS preload riski ve COOP/COEP/CORP'u gerçek header çıktılarıyla kuruyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10.0.13 runtime) ile derlenip denenmiştir; paket örneği NetEscapades.AspNetCore.SecurityHeaders 1.3.1 ile çalıştırılmıştır.
Önce Tahmin Et
Bookstore'un Angular uygulaması ASP.NET Core'dan servis ediliyor ve her yanıta CSP (Content Security Policy) header'ı ekleyen bir middleware yazdık. Güvenlik ekibi script'lere nonce istiyor; biz de her istekte yeni bir nonce üretip hem header'a hem index.html'e koyduk. Testte her şey çalıştı. Production'da önüne bir CDN (Content Delivery Network) konuldu ve bir hafta sonra güvenlik taraması, bütün kullanıcıların aynı nonce'u gördüğünü raporladı. Nasıl olabilir? İkinci soru: aynı sayfa için hem Nginx hem uygulama bir Content-Security-Policy header'ı gönderirse tarayıcı hangisini uygular?
Header'ların ne işe yaradığına, OWASP (Open Worldwide Application Security Project) Top 10 listesini anlatan Web Güvenliği serisinin "OWASP A02: Security Misconfiguration" yazısında ve ASP.NET Core Web API (Application Programming Interface) serisinin CORS (Cross-Origin Resource Sharing) ve güvenlik header'larını anlatan yazısında giriş yapmıştık. Bu yazının konusu header'ları ASP.NET Core'dan doğru üretmek: tek bir middleware, API ve HTML (HyperText Markup Language) için farklı setler, request başına nonce, raporlama endpoint'i ve HSTS (HTTP Strict Transport Security) preload kararı. Policy'nin içeriğini, yani Angular uygulaması için hangi directive'lerin yazılacağını, autoCsp'yi ve Trusted Types'ı ise Frontend Güvenliği serisinin üçüncü yazısında ele alıyoruz.
Nginx mi, Uygulama mı?
İlk karar, header'ın nerede üretileceği. Kural basit: değeri isteğe veya yanıta göre değişen header'lar uygulamada, her yanıtta aynı olanlar istersen edge'de. Nonce'lu CSP her istekte değişir, yanıtın HTML mi JSON (JavaScript Object Notation) mı olduğuna göre farklılaşır ve index.html'deki değerle aynı olmalıdır; bu yüzden uygulamada üretilmek zorunda. HSTS ise bütün domain için sabittir, TLS'i (Transport Layer Security) sonlandıran yer neresiyse orada durması doğaldır. Nginx tarafında header ve rate limit konusunu Web Server'lar ve Hosting serisinin yedinci yazısı anlatıyor.
Önemli olan, bir header'ın tek bir yerde üretilmesi. Açılıştaki ikinci sorunun cevabı burada: tarayıcı birden çok Content-Security-Policy header'ı görürse hepsini birden uygular; bir kaynak ancak her policy izin veriyorsa yüklenir. Nginx'te unutulmuş eski bir script-src 'self' policy'si, uygulamanın nonce'lu policy'sini sessizce daraltır ve hata ayıklaması çok zor bir "bazen çalışmıyor" durumu çıkar.
Tek Middleware, İki Header Seti
JSON dönen bir API endpoint'i ile Angular'ın index.html'ini servis eden fallback'in ihtiyaçları farklı. Hangi seti uygulayacağımıza yanıt başlarken, Content-Type belli olduktan sonra karar veriyoruz:
public sealed class CspNonce
{
public string Value { get; } = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
}
public sealed class SecurityHeadersMiddleware(RequestDelegate next, IConfiguration configuration)
{
private readonly bool _reportOnly = configuration.GetValue<bool>("SecurityHeaders:CspReportOnly");
public Task InvokeAsync(HttpContext context, CspNonce nonce)
{
context.Response.OnStarting(() =>
{
IHeaderDictionary headers = context.Response.Headers;
headers.XContentTypeOptions = "nosniff";
headers["Cross-Origin-Resource-Policy"] = "same-origin";
bool isHtml = context.Response.ContentType?.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) == true;
if (!isHtml)
{
// JSON yanıtı tarayıcıda belge olarak açılırsa hiçbir şey yüklenmesin
headers.ContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none'";
headers["Referrer-Policy"] = "no-referrer";
return Task.CompletedTask;
}
string csp = string.Join("; ",
"default-src 'self'",
$"script-src 'self' 'nonce-{nonce.Value}'",
$"style-src 'self' 'nonce-{nonce.Value}'",
"img-src 'self' data:",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"report-uri /csp-reports",
"report-to csp-endpoint");
headers[_reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy"] = csp;
headers["Reporting-Endpoints"] = "csp-endpoint=\"/csp-reports\"";
headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
headers["Cross-Origin-Opener-Policy"] = "same-origin";
headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(self)";
return Task.CompletedTask;
});
return next(context);
}
}
OnStarting önemli: middleware çalıştığında endpoint henüz Content-Type'ı yazmamıştır. Callback ise header'lar istemciye gönderilmeden hemen önce çalışır, bu yüzden hem endpoint'in hem static file middleware'inin yanıtlarını doğru sınıflandırır. Middleware'i pipeline'ın en başına koyuyoruz ki hata sayfaları ve 404'ler de header alsın. Nonce 16 byte'lık (128 bit) kriptografik rastgele bir değer; CspNonce scoped kaydedildiği için aynı istekte middleware ile endpoint aynı değeri görüyor. API yanıtları için gereken set çok küçük: nosniff, kimseyi yüklemeyen bir CSP ve no-referrer. JSON yanıtında script-src tanımlamanın bir anlamı yok; ama biri JSON'un URL'sini (Uniform Resource Locator) doğrudan tarayıcıda açarsa default-src 'none' o belgede hiçbir şeyin çalışmamasını garanti eder.
Request Başına Nonce ve Angular'ın index.html'i
Angular, ngCspNonce attribute'u taşıyan root element'ten nonce'u okur ve çalışma anında eklediği <style> element'lerine aynı nonce'u koyar. Build çıktısındaki index.html'e bir yer tutucu koyuyoruz:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Bookstore</title>
<base href="/">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<app-root ngCspNonce="__CSP_NONCE__"></app-root>
<script src="main.js" type="module"></script>
</body>
</html>
builder.Services.AddScoped<CspNonce>();
string indexTemplate = File.ReadAllText(Path.Combine(builder.Environment.WebRootPath, "index.html"));
var app = builder.Build();
app.UseMiddleware<SecurityHeadersMiddleware>();
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseStaticFiles();
app.UseRateLimiter();
app.MapGet("/api/books", () => new[] { new { id = 1, title = "Dune" } });
app.MapFallback((HttpContext context, CspNonce nonce) =>
{
context.Response.Headers.CacheControl = "no-store";
return Results.Content(indexTemplate.Replace("__CSP_NONCE__", nonce.Value), "text/html; charset=utf-8");
});
index.html dosyasını static file middleware'ine bırakmıyoruz; o dosyayı olduğu gibi, yer tutucusuyla servis ederdi. Template başlangıçta bir kez okunuyor, her istekte yalnız Replace yapılıyor. HTML ve API yanıtlarının gerçek header'ları:
$ curl -s -i http://127.0.0.1:19320/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: no-store
X-Content-Type-Options: nosniff
Cross-Origin-Resource-Policy: same-origin
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-Z1R4vM573EoLTLBpfn9jWA=='; style-src 'self' 'nonce-Z1R4vM573EoLTLBpfn9jWA=='; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; report-uri /csp-reports; report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="/csp-reports"
Referrer-Policy: strict-origin-when-cross-origin
Cross-Origin-Opener-Policy: same-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(self)
$ curl -s -i http://127.0.0.1:19320/api/books
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Content-Type-Options: nosniff
Cross-Origin-Resource-Policy: same-origin
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
Referrer-Policy: no-referrer
Ardışık iki istekte ngCspNonce değerleri 82p5lAEFCu+a/WPFju7jjg== ve p6BDw6kxWZAyouqiD8nxFA== oldu. Açılıştaki sorunun cevabı da burada: CDN index.html'i cache'lerse, ilk kullanıcıya üretilen nonce hem HTML'de hem header'da herkese dağıtılır. Nonce artık tahmin edilemez bir değer değildir; sayfaya script enjekte edebilen biri onu kaynak koddan okuyup kullanır. Cache-Control: no-store bu yüzden opsiyonel değil. CDN'ler bazen origin'in header'ını görmezden gelen kurallarla çalışır; CDN kuralını da HTML için "cache'leme" olarak ayarla ve doğrula.
Report-Only ile Başlamak ve Raporlama Endpoint'i
Var olan bir uygulamaya CSP'yi doğrudan zorunlu olarak koymak, ilk deploy'da bir şeyleri kırmanın en hızlı yolu. Content-Security-Policy-Report-Only header'ı hiçbir şeyi engellemez, yalnız ihlalleri raporlar. Raporların gideceği yeri iki yolla söylüyoruz: yeni Reporting API, Reporting-Endpoints header'ı ve report-to directive'iyle çalışır; eski report-uri ise Reporting API'yi desteklemeyen tarayıcılar için duruyor. İki format farklı gövdeler gönderdiği için endpoint ikisini de kabul ediyor:
public static async Task<IResult> ReceiveAsync(HttpRequest request, ILoggerFactory loggerFactory)
{
ILogger logger = loggerFactory.CreateLogger("Bookstore.CspReports");
string contentType = request.ContentType ?? "";
if (!contentType.StartsWith("application/reports+json", StringComparison.OrdinalIgnoreCase)
&& !contentType.StartsWith("application/csp-report", StringComparison.OrdinalIgnoreCase))
{
return Results.StatusCode(StatusCodes.Status415UnsupportedMediaType);
}
using JsonDocument document = await JsonDocument.ParseAsync(request.Body, cancellationToken: request.HttpContext.RequestAborted);
IEnumerable<JsonElement> reports = document.RootElement.ValueKind == JsonValueKind.Array
? document.RootElement.EnumerateArray().Take(MaxReportsPerRequest).Select(r => r.GetProperty("body"))
: [document.RootElement.GetProperty("csp-report")];
foreach (JsonElement report in reports)
{
logger.LogWarning("CSP violation: {Directive} blocked {Blocked} on {Document} ({Disposition})",
Read(report, "effectiveDirective", "effective-directive"),
Read(report, "blockedURL", "blocked-uri"),
Read(report, "documentURL", "document-uri"),
Read(report, "disposition", "disposition"));
}
return Results.NoContent();
}
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("csp-reports", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
});
app.MapPost("/csp-reports", CspReports.ReceiveAsync)
.WithMetadata(new RequestSizeLimitAttribute(16 * 1024))
.RequireRateLimiting("csp-reports");
Bu endpoint kimlik doğrulaması olmayan, internete açık ve içeriği tamamen istemcinin kontrolünde olan bir endpoint. Bu yüzden üç savunması var: gövde 16 KB ile sınırlı, IP (Internet Protocol) başına dakikada 10 istek, istek başına en fazla 20 rapor işleniyor ve log'a yazılan her alan 200 karakterde kesiliyor. Yerel uygulamaya iki format ve iki kötü istek gönderdim:
reports+json: 204
csp-report: 204
text/plain: 415
20 KB body: 413
204 204 204 204 204 204 429 429
warn: Bookstore.CspReports[0]
CSP violation: script-src-elem blocked inline on https://bookstore.test/checkout (report)
warn: Bookstore.CspReports[0]
CSP violation: style-src-elem blocked https://cdn.example.com/theme.css on https://bookstore.test/ (report)
Bir detay dikkatimi çekti: 413 doğru döndü ama Kestrel log'a fail seviyesinde An unhandled exception was thrown by the application ve BadHttpRequestException: Request body too large. The max request body size is 16384 bytes. yazdı. Gövde boyutu aşımı, gövde okunurken exception olarak ortaya çıkıyor. Bu endpoint'e büyük gövdeler gönderen biri hata log'unu doldurabilir; rate limit'in boyut kontrolünden önce çalışması bu yüzden önemli. Raporları bir iki hafta topla, meşru ihlalleri (unutulmuş bir analytics script'i, inline bir style) düzelt, sonra CspReportOnly değerini false yap. İkisini aynı anda da gönderebilirsin: zorunlu eski policy ve report-only yeni policy.
HSTS ve Preload'un Geri Dönüşü
HSTS, tarayıcıya "bu domain'e bir daha yalnız HTTPS (HTTP Secure) ile gel" der. ASP.NET Core'da UseHsts bunu yapar, ama yalnız HTTPS isteklerinde ve varsayılan olarak localhost, 127.0.0.1 ve [::1] hariç:
builder.Services.AddHsts(options =>
{
options.MaxAge = TimeSpan.FromDays(365);
options.IncludeSubDomains = true;
options.Preload = false;
});
== hsts https bookstore.test
HTTP/2 200
strict-transport-security: max-age=31536000; includeSubDomains
== hsts https localhost
HTTP/2 200
== hsts http
HTTP/1.1 200 OK
Yerelde HSTS'yi test ederken header'ı göremeyip middleware'in çalışmadığını sanmak sık yapılan bir hata; localhost bilerek hariç tutuluyor. Preload = true, header'a preload ekler ve domain'i hstspreload.org üzerinden tarayıcıların içine gömülü listeye başvurmaya uygun hâle getirir. Liste tarayıcı sürümleriyle dağıtıldığı için listeden çıkmak aylar sürebilir; bu sürede includeSubDomains yüzünden HTTP'den (Hypertext Transfer Protocol) başka bir şey konuşamayan her alt domain (eski bir intranet uygulaması, bir yazıcı paneli) erişilemez olur. Preload'u ancak bütün alt domain'lerin envanterini çıkardıktan sonra aç. Microsoft'un dokümanı da HTTPS'e ilk kez geçen bir production ortamında MaxAge'i birkaç saatle, en fazla bir günle başlatmayı, HTTPS altyapısından emin olunca (genelde bir yıla) artırmayı öneriyor. MaxAge verilmezse varsayılan 30 gün.
COOP, COEP, CORP ve Permissions-Policy
Bu üç header, OWASP HTTP Headers Cheat Sheet'in de ayrı ayrı ele aldığı cross-origin izolasyon ailesi. CORP (Cross-Origin-Resource-Policy) bir kaynak için "beni kim yükleyebilir" sorusunu cevaplar: same-origin ile resimlerimizi ve JSON'larımızı başka bir sitenin <img> ya da <script> ile çekmesini engelliyoruz. COOP (Cross-Origin-Opener-Policy) same-origin, sayfamızı window.open ile açan başka bir origin'in window.opener üzerinden ona erişmesini keser. COEP (Cross-Origin-Embedder-Policy) ise sayfanın yükleyeceği her cross-origin kaynağın CORP ya da CORS ile açıkça izin vermesini şart koşar. COOP ve COEP birlikte sayfayı "cross-origin isolated" yapar ve SharedArrayBuffer gibi API'leri açar. Bookstore buna ihtiyaç duymadığı için COEP göndermiyoruz; bilinçsizce require-corp koymak, CORP header'ı olmayan bir ödeme widget'ını ya da harici bir font'u kırar.
Permissions-Policy tarayıcı özelliklerini sayfaya ve iframe'lere kapatır: kamera, mikrofon ve konum kapalı, Payment Request API'si yalnız kendi origin'imize açık. frame-ancestors 'none' clickjacking'e karşı eski X-Frame-Options'ın yerini alır; çok eski tarayıcıları desteklemen gerekiyorsa ikisini birlikte gönder.
Hazır Paket: NetEscapades.AspNetCore.SecurityHeaders
Bunları elle yazmak yerine Andrew Lock'un NetEscapades.AspNetCore.SecurityHeaders paketini kullanabilirsin. 1.x sürümleri endpoint başına policy'yi destekliyor:
using NetEscapades.AspNetCore.SecurityHeaders;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSecurityHeaderPolicies()
.SetDefaultPolicy(policy => policy.AddDefaultApiSecurityHeaders())
.AddPolicy("Spa", policy => policy
.AddDefaultSecurityHeaders()
.AddContentSecurityPolicy(csp =>
{
csp.AddDefaultSrc().Self();
csp.AddScriptSrc().Self().WithNonce();
csp.AddStyleSrc().Self().WithNonce();
csp.AddObjectSrc().None();
csp.AddBaseUri().Self();
csp.AddFrameAncestors().None();
}));
var app = builder.Build();
app.UseSecurityHeaders();
app.MapGet("/api/books", () => new[] { new { id = 1, title = "Dune" } });
app.MapGet("/", (HttpContext context) =>
Results.Content($"<app-root ngCspNonce=\"{context.GetNonce()}\"></app-root>", "text/html"))
.WithSecurityHeadersPolicy("Spa");
app.Run();
Content-Security-Policy: script-src 'self' 'nonce-zKm4yU3nPx1MvdCsUfRI/NBoZoicYxF+1nW7ojrgt9E='; style-src 'self' 'nonce-zKm4yU3nPx1MvdCsUfRI/NBoZoicYxF+1nW7ojrgt9E='; default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
Cross-Origin-Resource-Policy: same-site
Paket varsayılanlarına dikkat: AddDefaultSecurityHeaders COEP'i credentialless, API seti ise require-corp olarak ekliyor; yukarıda anlattığım widget sorununu bu yüzden test et. Elle yazmanın avantajı her satırı bilmen; paketin avantajı endpoint bazlı policy seçimi ve iyi test edilmiş bir builder API'si. Bookstore gibi tek bir SPA (single-page application) ve bir API için elle yazılmış 40 satırlık middleware yeterli.
Üretimde Dikkat
Header'ları her deploy'da otomatik doğrula. Integration test'te / ve /api/books yanıtlarının header'larını kontrol etmek, birinin middleware sırasını değiştirmesini ya da ikinci bir CSP eklemesini yakalar. Rapor endpoint'inin log'unu bir metrik olarak izle: Report-Only'den zorunlu moda geçtikten sonra rapor sayısındaki ani artış, ya yeni bir deploy'un policy'yi kırdığını ya da birinin sayfaya script enjekte etmeye çalıştığını gösterir.
CSP raporları tarayıcı eklentilerinden gelen gürültüyle doludur; blockedURL değeri chrome-extension gibi şemalarla başlayan raporları ayrı say. Raporları hiçbir zaman doğrudan bir HTML panelde göstermeyin: içerik tamamen istemcinin kontrolünde ve log görüntüleyicinde XSS'e (cross-site scripting) dönüşebilir.
Sık Düşülen Tuzaklar
Nonce'lu HTML'i cache'lemek: CDN, reverse proxy cache'i ya da ASP.NET Core'un output caching'i nonce'u herkese aynı dağıtır. HTML yanıtında no-store olmalı.
Aynı header'ı iki katmanda üretmek. İki CSP birlikte uygulanır ve yalnız ikisinin de izin verdiği kaynaklar yüklenir; iki HSTS header'ından ise RFC (Request for Comments) 6797'ye, yani HSTS spesifikasyonuna göre tarayıcı yalnız ilkini işler, hangisinin ilk olduğu da katmanların sırasına kalır.
'unsafe-inline''ı nonce ile birlikte bırakmak. CSP Level 2 ve sonrasını destekleyen tarayıcılar nonce varken 'unsafe-inline''ı yok sayar, ama nonce'u tanımayan eski tarayıcılar 'unsafe-inline''ı uygular; policy tarayıcıya göre farklı güçte olur. Bunun ayrıntısı Frontend Güvenliği serisinde.
HSTS'yi Development ortamında açmak. Yerelde localhost hariç tutulduğu için zarar vermez gibi görünür, ama bir test domain'iyle (bookstore.test) çalışıyorsan tarayıcın o domain'i bir yıl boyunca yalnız HTTPS ile açmaya çalışır.
Küçük Bir Deneme
★☆☆ CspReportOnly değerini false yap, index.html'e nonce'suz bir inline <script> ekle ve tarayıcının konsolunda ihlal mesajını, rapor endpoint'inin log'unda da raporu bul.
★★☆ Middleware'e, CSP'yi yalnız ilk kez ekleyen bir kontrol koy: yanıtta zaten bir Content-Security-Policy varsa (başka bir middleware ya da endpoint eklediyse) üzerine yazma, bir uyarı log'la. Bunu bir integration test'le doğrula.
★★★ Rapor endpoint'ini aynı ihlali dakikada bir kez log'layacak şekilde değiştir: effectiveDirective ve blockedURL çiftini key olarak kullanan küçük bir memory cache ile tekrarları say ve dakikalık özet yaz.
Header'lar tarayıcıya ne yapmaması gerektiğini söyledi. Bir sonraki yazıda tarayıcının otomatik olarak cookie gönderdiği durumların kötüye kullanılmasına, yani CSRF'ye (cross-site request forgery) ve ASP.NET Core'un antiforgery sistemine derinlemesine bakıyoruz.