Mert Özen Her Satırda Daha İleri
Güvenlik Web Güvenliği (OWASP Top 10) — #05

OWASP A02: Security Misconfiguration — Hata Sayfaları, OpenAPI, Header'lar ve XXE

Mert Özen 6 Kas 2026 10 dk 5 görüntülenme
OWASP A02: Security Misconfiguration — Hata Sayfaları, OpenAPI, Header'lar ve XXE

Kod aynı, ayarlar yanlış: Production'da koşulsuz açılmış developer exception page stack trace'i döküyor, unutulmuş MapOpenApi satırı API'nin bütün haritasını müşteriye veriyor. ProblemDetails, ortam bazında yapılandırma, AllowedHosts, kapatılan Server header'ı, CSP ve nosniff için küçük bir middleware ve HSTS'in localhost'ta neden görünmediğiyle düzeltiyor; son olarak DTD içeren bir XML belgesini reddeden sıkı bir XmlReader kuruyoruz.

Sürüm notu: Bu yazı OWASP Top 10:2025'e göre yazılmıştır; kodlar ASP.NET Core 10 (.NET 10) ile denenmiştir.

Önce Tahmin Et

Lab'ı bu kez Production ortamında çalıştırıyoruz. Giriş yapmamış biri hata veren bir endpoint'e istek atınca cevapta ne görür? Sıradan bir müşteri olan Alan /openapi/v1.json adresine gidince ne alır? Cevabın header'larından hangisi tarayıcıya bir güvenlik kuralı söylüyor, hangisi yalnız gereksiz bilgi veriyor?

Bu yazının konusu, kodun doğru ama ayarların yanlış olduğu durumlar. Yine yalnız kendi makinendeki lab'da çalışıyoruz; bu teknikleri yalnız sahibi olduğun ya da yazılı izin aldığın sistemlerde dene.

Misconfiguration: Kod Doğru, Ayar Yanlış

A02:2025 Security Misconfiguration (güvenlik yanlış yapılandırması) 2021'deki beşinci sıradan ikinciye çıktı ve altında 16 CWE topluyor. Kategori 2021'den beri XXE'yi de (CWE-611) kapsıyor; 2017'de XXE kendi başına A4'tü. Ortak payda şu: framework'ün ya da sunucunun bir ayarı, bir varsayılanı ya da açık bırakılmış bir özelliği, yazdığın koddan bağımsız olarak saldırgana bilgi ya da kapı bırakıyor.

ASP.NET Core bu konuda çoğu şeyi doğru yapıyor: developer exception page yalnız Development'ta kendiliğinden açılıyor, Web API şablonu OpenAPI dokümanını Development koşulunun içine koyuyor, web uygulaması şablonları HSTS'i yalnız Development dışında ekliyor. Sorun, bu güvenli varsayılanların tek satırla bozulabilmesi. O satırların çoğu da "şimdilik" diye eklenip unutuluyor.

Production'da Konuşkan Bir Hata Sayfası

Lab'a ikinci yazıdaki bilerek bozuk endpoint'i geri ekledim: /books/3/summary, yorumu olmayan üçüncü kitapta null bir yorumun Body'sine erişiyor. Bir de Production'daki bir hatayı incelemek için şu satırların eklendiğini ve öyle kaldığını düşünelim:

builder.Services.AddOpenApi();

var app = builder.Build();

// Production'daki bir hatayı görmek için eklendi ve unutuldu
app.UseDeveloperExceptionPage();
app.MapOpenApi();

Bu yazıda http profili 5110'da, https profili 5111'de. Uygulamayı Production ortamında başlatıp giriş yapmadan istek atalım:

$ dotnet run --launch-profile https -e ASPNETCORE_ENVIRONMENT=Production
...
$ curl -i https://localhost:5111/books/3/summary
HTTP/2 500
content-type: text/plain; charset=utf-8
date: Fri, 06 Nov 2026 19:30:44 GMT
server: Kestrel

System.NullReferenceException: Object reference not set to an instance of an object.
   at Program.<>c.<<<Main>$>b__0_12>d.MoveNext() in /.../Program.cs:line 153
--- End of stack trace from previous location ---
   at Microsoft.AspNetCore.Http.RequestDelegateFactory.ExecuteTaskResult[T](Task`1 task, HttpContext httpContext)
   at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

HEADERS
=======
Accept: */*
Host: localhost:5111
User-Agent: curl/8.7.1

Ortam Production, ama ikinci yazıda Development'ta gördüğümüz bütün ayrıntı geri geldi: exception tipi, dosya yolu, satır numarası, middleware sırası ve isteğin header'ları. UseDeveloperExceptionPage()'i elle çağırdığın anda ortam kontrolü ortadan kalkıyor. Düzeltme, ortam ayrımını koda geri koymak ve Production'a hatayı söyleyen ama ayrıntısını vermeyen bir handler eklemek:

builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}
else
{
    app.UseExceptionHandler();
    app.UseHsts();
}

app.UseHttpsRedirection();
$ curl -i https://localhost:5111/books/3/summary
HTTP/2 500
content-type: application/problem+json
date: Fri, 06 Nov 2026 19:31:26 GMT
cache-control: no-cache,no-store
expires: -1
pragma: no-cache
...

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-b32969c9f12da17a6bd1af9e5637b1c3-b4d22578e884e87e-00"}

İstemci yalnız bir hata olduğunu ve bir traceId görüyor. Stack trace sunucu log'unda, aynı trace id'yle aranabilir hâlde duruyor. UseExceptionHandler()'ı parametresiz çağırmak için AddProblemDetails() gerekiyor. Hata yönetiminin geri kalanı, yani IExceptionHandler ve fail closed davranışı A10 yazısının konusu.

Gereksiz Endpoint'ler: Açık Kalan OpenAPI Dokümanı

.NET 9'dan beri Web API şablonu Swashbuckle yerine Microsoft.AspNetCore.OpenApi paketiyle geliyor; lab'a da 10.0.14 sürümünü ekledim. Yukarıdaki unutulmuş MapOpenApi() satırı Production'da ne yayınlıyor? Fallback policy sayesinde giriş yapmamış biri 401 alıyor, ama Alan'ın cookie'si yetiyor:

$ curl -s -b alan.txt https://localhost:5111/openapi/v1.json | grep '^    "/'
    "/login": {
    "/books": {
    "/orders/{id}": {
    "/admin/users": {
    "/me": {
    "/downloads": {
    "/books/{id}/cover": {
    "/books/{id}/summary": {
    "/orders/import": {

Bu, API'nin bütün haritası: admin endpoint'leri, parametre tipleri ve dönen modellerin alanları. Önceki yazılarda bu endpoint'leri doğru korumuş olsak bile saldırganın keşif işini bedavaya yapmış oluyoruz. Fallback policy'nin burada kısmen işe yaraması da tesadüf; ilk yazılardaki gibi her şey anonim olsaydı doküman herkese açık olurdu. Düzeltilmiş hâlde MapOpenApi() yalnız Development'ta çalışıyor:

$ curl -i -b alan.txt https://localhost:5111/openapi/v1.json
HTTP/2 404
date: Fri, 06 Nov 2026 19:31:26 GMT
content-length: 0
...

Aynı mantık Swagger UI, health check ayrıntıları, /debug gibi yardımcı endpoint'ler ve yapılandırmayı döken teşhis sayfaları için de geçerli. Production'da gerçekten gerekiyorsa ayrı bir policy ile korunmalı, gerekmiyorsa hiç map edilmemeli.

Varsayılanlar ve Ortam Bazında Yapılandırma

Yukarıdaki ilk cevaplarda bir satır daha vardı: server: Kestrel. Tek başına bir açık değil ama karşı tarafa hangi sunucuyu kullandığını bedavaya söylüyor. Kestrel'de tek satırla kapanıyor:

builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);

Daha önemli bir varsayılan appsettings.json'daki "AllowedHosts": "*". Host filtering middleware'i, isteğin Host header'ını bu listeyle karşılaştırıyor ve yıldız her değeri kabul ediyor. Uygulama Host header'ından mutlak URL üretiyorsa (parola sıfırlama linki gibi), bu değeri isteği atan belirlemiş olur. Production için ayrı bir dosyada gerçek alan adını yazıyoruz; localhost yalnız lab için listede:

{
  "AllowedHosts": "bookstore.test;localhost"
}
$ curl -i https://localhost:5111/books -H "Host: other.test"
HTTP/2 400
content-type: text/html
date: Fri, 06 Nov 2026 19:31:26 GMT
content-length: 334

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN""http://www.w3.org/TR/html4/strict.dtd">
<HTML><HEAD><TITLE>Bad Request</TITLE>
<META HTTP-EQUIV="Content-Type" Content="text/html; charset=us-ascii"></ HEAD >
<BODY><h2>Bad Request - Invalid Hostname</h2>
<hr><p>HTTP Error 400. The request hostname is invalid.</p>
</BODY></HTML>

Bu dosyanın adı appsettings.Production.json. ASP.NET Core yapılandırmayı katman katman okur: önce appsettings.json, sonra appsettings.{Environment}.json, Development'ta user secrets, ardından ortam değişkenleri ve komut satırı. Sonra gelen öncekini ezer. Bu düzen, güvenlik ayarlarını ortama göre yönetmenin doğal yolu: ortak değerler ana dosyada, Production'a özel sıkı değerler kendi dosyasında, secret'lar hiçbir dosyada değil. Secret yönetimini A04 yazılarında ayrıca konuşacağız.

Bir varsayılan da bizim lab'ımızda duruyor: Initialize() her ortamda ada kullanıcısını dev-only-ada parolasıyla oluşturuyor. Lab için sorun değil, ama gerçek projede örnek veriyi yalnız Development'ta yüklemek gerekir; bilinen bir parolayla gelen admin hesabı, varsayılan kimlik bilgisinin ta kendisi.

Security Header'lar İçin Küçük Bir Middleware

Bazı korumaları sunucu değil tarayıcı uygular; sunucunun işi doğru header'ı göndermek. ASP.NET Core'da HSTS için yerleşik middleware var, CSP ve diğerleri için yok. Birkaç satırlık bir middleware yeter:

app.Use(async (context, next) =>
{
    // Header'lar cevap yola çıkmadan hemen önce eklenir; hata cevapları da dahil
    context.Response.OnStarting(() =>
    {
        IHeaderDictionary headers = context.Response.Headers;
        // API HTML üretmiyor: hiçbir kaynak yüklenmesin, hiçbir sayfa bizi frame'lemesin
        headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'";
        headers["X-Content-Type-Options"] = "nosniff";
        headers["Referrer-Policy"] = "no-referrer";
        return Task.CompletedTask;
    });
    await next(context);
});

Bookstore bir JSON API olduğu için CSP'si en sıkı hâlinde: default-src 'none' bir cevap bir şekilde tarayıcıda belge olarak açılırsa hiçbir script'in ya da kaynağın yüklenmemesini, frame-ancestors 'none' hiçbir sayfanın API'yi bir iframe'e gömememesini söylüyor. Eski tarayıcılar için aynı işi X-Frame-Options: DENY yapıyordu. nosniff, tarayıcının Content-Type'ı tahmin etmeye çalışıp JSON'u script ya da HTML gibi yorumlamasını engelliyor. HTML üreten bir uygulamanın, örneğin Angular'ın sunulduğu sitenin CSP'si çok daha ayrıntılı; onu XSS yazısında ele alacağız.

OnStarting kullanmamın bir sebebi var. İlk denemede header'ları doğrudan next'ten önce ekliyordum; 200 cevaplarında görünüyorlardı ama 500 ProblemDetails cevabında üçü de yoktu, çünkü exception handler hata cevabını yazmadan önce cevabı temizliyor. OnStarting ise cevap gönderilmeden hemen önce çalıştığı için hata cevaplarında da header'lar duruyor. HSTS için ise yerleşik ayarları sıkılaştırdım; varsayılan max-age 30 gün:

builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(365);
    options.IncludeSubDomains = true;
});
$ curl -i https://localhost:5111/books
HTTP/2 200
content-type: application/json; charset=utf-8
date: Fri, 06 Nov 2026 19:31:26 GMT
content-security-policy: default-src 'none'; frame-ancestors 'none'
x-content-type-options: nosniff
referrer-policy: no-referrer
...

$ curl -i https://localhost:5111/books -H "Host: bookstore.test"
HTTP/2 200
content-type: application/json; charset=utf-8
date: Fri, 06 Nov 2026 19:31:26 GMT
strict-transport-security: max-age=31536000; includeSubDomains
content-security-policy: default-src 'none'; frame-ancestors 'none'
x-content-type-options: nosniff
referrer-policy: no-referrer
...

İlk istekte HSTS yok ve bu bir hata değil: UseHsts'in ExcludedHosts listesinde varsayılan olarak localhost var, çünkü tarayıcı bu header'ı bir kez gördüğünde o host için HTTPS'i aylarca zorunlu tutar ve geliştirme makinende başka projeleri bozabilir. Header'ı görmek için Host'u Production alan adıyla gönderdim. HSTS yalnız HTTPS cevaplarında anlamlı; düz HTTP isteğini ise UseHttpsRedirection 307 ile HTTPS'e yönlendiriyor. IncludeSubDomains'i açmadan önce bütün alt alan adlarının HTTPS desteklediğinden emin ol; tarayıcıların HSTS preload listesine girmek ise geri alması zor bir karar.

XXE: XML Okurken DTD'ye Kapıyı Kapatmak

Bookstore'a, eski bir sistemden gelen siparişleri XML olarak alan bir import endpoint'i ekliyoruz. XML'in bir özelliği, belgenin başındaki DTD (Document Type Definition) içinde entity tanımlayabilmesi. Bir entity belgenin içinde başka bir metnin yerine geçer. Dış entity (external entity) ise içeriğini bir dosya yolundan ya da URL'den alır. Parser dış entity'leri çözmeye ayarlıysa, saldırgan gönderdiği belgeyle sunucunun bir dosyayı okuyup cevaba koymasını ya da sunucunun bir iç adrese istek atmasını sağlayabilir; bu XXE (XML External Entity). İç içe entity'lerle küçük bir belgeyi bellekte devasa bir metne açmak da mümkün (CWE-776). İki tehlikeli ayar şunlar:

XmlReaderSettings settings = new()
{
    // DTD'yi işle ve dış kaynakları çözmesine izin ver: XXE'nin iki şartı
    DtdProcessing = DtdProcessing.Parse,
    XmlResolver = new XmlUrlResolver()
};

Modern .NET'te XmlReaderSettings.DtdProcessing'in varsayılanı Prohibit; new XmlReaderSettings().DtdProcessing değerini yazdırınca bunu görüyorsun. Yani bu kodu birinin bilerek yazması gerekiyor, genelde de "DTD içeren bir dosya geldi, hata verdi" şikâyetinin ardından. Doğru endpoint, varsayılanı açıkça yazar ve hatayı genel bir mesajla döner:

app.MapPost("/orders/import", async (HttpRequest request, ILogger<Program> logger) =>
{
    XmlReaderSettings settings = new()
    {
        // Prohibit zaten varsayılan; açıkça yazmak niyeti belgeliyor
        DtdProcessing = DtdProcessing.Prohibit,
        XmlResolver = null,
        Async = true
    };

    try
    {
        using XmlReader reader = XmlReader.Create(request.Body, settings);
        int imported = 0;
        while (await reader.ReadAsync())
        {
            if (reader.NodeType == XmlNodeType.Element && reader.Name == "order")
            {
                imported++;
            }
        }
        return Results.Ok(new { imported });
    }
    catch (XmlException ex)
    {
        // Ayrıntı log'a, istemciye genel bir mesaj
        logger.LogWarning(ex, "Rejected XML import");
        return Results.Problem("The XML document is not valid.", statusCode: StatusCodes.Status400BadRequest);
    }
}).RequireAuthorization("AdminOnly");

Async = true ve ReadAsync gerekli, çünkü Kestrel request body'sinin senkron okunmasına varsayılan olarak izin vermiyor. Denemek için iki dosya hazırladım. orders.xml düz bir sipariş listesi. orders-dtd.xml aynı belgenin başına tek satırlık, tamamen zararsız bir DTD ekliyor; içinde hiçbir entity yok:

<?xml version="1.0"?>
<!DOCTYPE orders [
  <!ELEMENT orders ANY>
]>
<orders>
  <order book="1" quantity="2" />
</orders>
$ curl -i -b ada.txt -X POST https://localhost:5111/orders/import -H "Content-Type: application/xml" --data-binary @orders.xml
HTTP/2 200
content-type: application/json; charset=utf-8
date: Fri, 06 Nov 2026 19:31:39 GMT
...

{"imported":2}

$ curl -i -b ada.txt -X POST https://localhost:5111/orders/import -H "Content-Type: application/xml" --data-binary @orders-dtd.xml
HTTP/2 400
content-type: application/problem+json
date: Fri, 06 Nov 2026 19:31:39 GMT
...

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"The XML document is not valid.","traceId":"00-3ea3577d1ed6f4e721f372865e3cf03e-392697a2dc62bdf6-00"}

Sunucu log'unda gerçek sebep duruyor:

warn: Program[0]
      Rejected XML import
      System.Xml.XmlException: For security reasons DTD is prohibited in this XML document. To enable DTD processing set the DtdProcessing property on XmlReaderSettings to Parse and pass the settings into XmlReader.Create method.

Zararsız bir DTD'nin de reddedilmesi bilinçli: parser, içinde entity olup olmamasına bakmadan DOCTYPE'ı gördüğü anda duruyor. Bir sipariş import'unun DTD'ye ihtiyacı yok. XDocument, XmlDocument ya da XmlSerializer kullanıyorsan da aynı ayarlarla kendi XmlReader'ını oluşturup onlara vermek, hangi varsayılanla çalıştığını tahmin etmekten daha güvenli.

Sık Düşülen Tuzaklar

En yaygını, Production'daki bir hatayı anlamak için sunucuyu geçici olarak Development'a almak ya da UseDeveloperExceptionPage()'i koşulsuz eklemek. Doğru yol log'a bakmak; ProblemDetails'teki traceId bunun için orada.

İkincisi, güvenlik header'larını yalnız başarılı cevaplarda göndermek. Yukarıda gördüğümüz gibi exception handler cevabı temizleyebiliyor; header'larını hata cevabıyla da kontrol et. Header'ları reverse proxy'de mi uygulamada mı eklediğine de karar ver; iki yerde birden eklersen cevapta iki CSP olur ve tarayıcı ikisini birden uygular, bu da beklemediğin şeylerin bloklanması demek.

Üçüncüsü, ayarı tek bir ortamda test etmek. HSTS'in localhost'ta görünmemesi, AllowedHosts'un Development'ta yıldız olması, OpenAPI'nin yalnız Development'ta açık olması doğru davranışlar; ama Production'ın nasıl davrandığını görmek için uygulamayı en az bir kez ASPNETCORE_ENVIRONMENT=Production ile çalıştırıp cevapları gözle kontrol etmen gerekiyor.

Kontrol Listesi

Developer exception page ve OpenAPI dokümanı yalnız Development'ta mı açık, Production'da hatalar ProblemDetails ile mi dönüyor? AllowedHosts Production'da gerçek alan adlarını mı listeliyor, Server header'ı kapalı mı? Her cevapta, hata cevapları dahil, CSP, X-Content-Type-Options ve Referrer-Policy var mı; HTTPS cevaplarında HSTS doğru max-age ile geliyor mu? Örnek kullanıcılar ve bilinen parolalar yalnız Development'ta mı yükleniyor? XML okuyan her yerde DTD kapalı ve resolver yok mu?

Küçük Bir Deneme

★☆☆ Lab'ı Development ve Production ortamlarında ayrı ayrı çalıştır ve /books/3/summary, /openapi/v1.json ve /books cevaplarının header'larını yan yana koy. Hangi farklar kodda, hangileri yapılandırmada?

★★☆ Header middleware'ini UseSecurityHeaders() adında bir extension method'a taşı, header değerlerini appsettings.json'dan oku ve listeye bir Permissions-Policy ekle.

★★★ XML import'a bir boyut sınırı ekle: XmlReaderSettings.MaxCharactersInDocument ile belge boyutunu sınırla, endpoint'e de RequestSizeLimit uygula. İkisi arasındaki fark ne, hangisi daha erken devreye giriyor?

Bir sonraki yazıda A03:2025 Software Supply Chain Failures'a geçiyoruz: kendi kodun ne kadar temiz olursa olsun, kullandığın paketler, build ve dağıtım zinciri üzerinden gelen riskler; NuGetAudit, npm audit, lock dosyaları ve SBOM.