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

OWASP A01: Broken Access Control (2) — Path Traversal, CORS ve SSRF

Mert Özen 6 Kas 2026 12 dk 6 görüntülenme
OWASP A01: Broken Access Control (2) — Path Traversal, CORS ve SSRF

Dosya adını istemciden alan bir indirme endpoint'i, her origin'i geri yazan bir CORS policy'si ve verilen URL'ye istek atan bir kapak indirme kodu. Üç zafiyetli kalıbı inceleyip kanonik yol kontrolü, origin'leri tek tek listeleyen bir policy ve allowlist, kapalı yönlendirme ve ConnectCallback ile IP kontrolü yapan bir HttpClient ile kapatıyor; düzeltilmiş kodun kötü girdiyi nasıl reddettiğini lab'da görüyoruz.

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

Bookstore API'ye kitapların örnek bölümlerini indiren bir endpoint ekliyoruz: GET /downloads?name=clean-code-sample.txt. Sunucu, gelen dosya adını downloads klasörünün yoluna ekleyip dosyayı okuyor. name parametresine ../appsettings.json yazılırsa hangi dosya döner? İkinci soru: admin, kitabın kapak resmini bir URL vererek sunucuya indirtiyor. O URL sunucunun kendi ağındaki bir adresi gösterirse isteği kim atmış olur?

Bu yazı A01:2025 Broken Access Control'ün (bozuk erişim kontrolü) ikinci yarısı. Bugünkü üç konu aynı soruya çıkıyor: istemcinin verdiği bir değer, sunucunun erişebildiği ama istemcinin erişmemesi gereken bir şeye kapı açıyor. Sırasıyla dosya sistemi, tarayıcının same-origin policy'si ve sunucunun ağı.

Zafiyetli kalıpları kod olarak gösterip neden tehlikeli olduklarını anlatacağım; lab'da çalıştırdığımız istekler ise düzeltilmiş kodun kötü girdiyi nasıl reddettiğini gösterecek. Bu teknikleri yalnız sahibi olduğun ya da yazılı izin aldığın sistemlerde dene.

Path Traversal: Dosya Adına Güvenmek

Lab, bir önceki yazıdaki projeden devam ediyor; bu kez http profili 5106 port'unda. Proje klasöründe bir downloads klasörü ve içinde clean-code-sample.txt var. İndirme endpoint'inin ilk akla gelen hâli şu:

app.MapGet("/downloads", (string name, IWebHostEnvironment env) =>
{
    // Dosya adı istemciden geliyor ve olduğu gibi yola ekleniyor
    string path = Path.Combine(env.ContentRootPath, "downloads", name);
    return File.Exists(path) ? Results.File(path, "text/plain") : Results.NotFound();
}).AllowAnonymous();

Kod, name'in hep yalın bir dosya adı olacağını varsayıyor. İşletim sistemi için ise .. "bir üst klasör" demek: ../appsettings.json uygulamanın yapılandırma dosyasını gösterir, yeterince ../ eklenirse uygulamanın okuyabildiği her dosyayı. Adı path traversal, CWE karşılığı CWE-22. Süreç o dosyaları okuyabiliyor, istemci okuyamamalı; aradaki sınırı yalnız bizim kodumuz çiziyor.

Path.Combine'ın bir sürprizi daha var: argümanlardan biri kök bir yolsa (/etc/hosts ya da C:\ ile başlayan bir değer) kendinden öncekileri atar. Yani .. aramak yetmez. Kestrel URL path'indeki .. segmentlerini normalleştiriyor (/x/../books isteği /books'a düşüyor), ama query string'e ve body'ye dokunmuyor.

Kanonik Yol ve Kök Klasör Kontrolü

Düzeltme: yolu önce işletim sisteminin göreceği son hâline, yani kanonik yola çevir, sonra hâlâ izin verdiğin klasörün içinde mi diye bak.

string downloadsRoot = Path.Join(Path.GetFullPath(Path.Join(app.Environment.ContentRootPath, "downloads")),
    Path.DirectorySeparatorChar.ToString());

app.MapGet("/downloads", (string name) =>
{
    string fullPath = Path.GetFullPath(Path.Join(downloadsRoot, name));
    // Kanonik yol kök klasörün içinde değilse dosya sistemine hiç dokunma
    if (!fullPath.StartsWith(downloadsRoot, StringComparison.Ordinal))
    {
        return Results.BadRequest();
    }
    return File.Exists(fullPath) ? Results.File(fullPath, "text/plain") : Results.NotFound();
}).AllowAnonymous();

Path.GetFullPath .. ve . segmentlerini çözer. Path.Join, Path.Combine'ın aksine kök bir ikinci argümanda ilkini atmaz. Kök yolun sonundaki ayraç da bilinçli: o olmasa .../downloads-private gibi komşu bir klasör de kontrolden geçerdi.

$ curl -i "http://localhost:5106/downloads?name=clean-code-sample.txt"
HTTP/1.1 200 OK
Content-Length: 28
Content-Type: text/plain
Date: Fri, 06 Nov 2026 19:18:00 GMT
Server: Kestrel
Last-Modified: Fri, 06 Nov 2026 19:17:20 GMT

Clean Code - sample chapter

$ curl -i "http://localhost:5106/downloads?name=../appsettings.json"
HTTP/1.1 400 Bad Request
Content-Length: 0
Date: Fri, 06 Nov 2026 19:18:00 GMT
Server: Kestrel

$ curl -i "http://localhost:5106/downloads?name=..%2F..%2FProgram.cs"
HTTP/1.1 400 Bad Request
Content-Length: 0
Date: Fri, 06 Nov 2026 19:18:00 GMT
Server: Kestrel

$ curl -i "http://localhost:5106/downloads?name=/etc/hosts"
HTTP/1.1 404 Not Found
Content-Length: 0
Date: Fri, 06 Nov 2026 19:18:00 GMT
Server: Kestrel

.. içeren iki değer 400 alıyor; ikincisi URL encoding'li, ama ASP.NET Core query string'i decode ettiğinden kontrol son değeri görüyor. /etc/hosts ise Path.Join sayesinde downloads/etc/hosts oluyor ve böyle bir dosya olmadığı için 404.

En sağlamı, istemciden dosya adı hiç almamak: kitabın id'sini al, dosyayı sunucuda eşle. Mecbursan Path.GetFileName(name) == name ile klasör içeren her değeri reddet, uzantılara allowlist uygula. Statik dosyalarda bu işi UseStaticFiles'ın kullandığı PhysicalFileProvider zaten yapıyor; sorun hep elle yazılmış indirme endpoint'lerinde çıkıyor.

CORS Ne Yapar, Ne Yapmaz

Tarayıcı, bir sayfadaki JavaScript'in başka bir origin'e (scheme, host ve port üçlüsü) attığı isteğin cevabını varsayılan olarak o koda vermez; buna same-origin policy deniyor. CORS (Cross-Origin Resource Sharing) bu kuralı sunucunun isteğiyle gevşetir: sunucu Access-Control-Allow-Origin header'ıyla "şu origin cevabımı okuyabilir" der. http://localhost:4200'deki Angular uygulaması ile 5106'daki API, port farklı olduğu için iki ayrı origin.

Sorun genelde geliştirme sırasında, "hepsine izin verelim" cümlesiyle başlar. ASP.NET Core en bariz hatayı kendisi engelliyor: her origin'e izin verip credential'ları, yani cookie'leri de açarsan uygulama ayağa kalkmıyor.

builder.Services.AddCors(options =>
{
    // Her origin'e izin ver ve cookie'leri de gönder: iki ayar bir arada olamaz
    options.AddPolicy("Frontend", policy => policy
        .AllowAnyOrigin()
        .AllowCredentials());
});
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
      Hosting failed to start
      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.
         at Microsoft.AspNetCore.Cors.Infrastructure.CorsPolicyBuilder.Build()
         at Microsoft.AspNetCore.Cors.Infrastructure.CorsOptions.AddPolicy(String name, Action`1 configurePolicy)

Kısıt CORS protokolünden geliyor; tarayıcı da credential'lı istekte * kabul etmiyor. Sık rastlanan "çözüm" ise kuralın etrafından dolaşmak:

builder.Services.AddCors(options =>
{
    // Gelen her origin'i kabul et ve cookie'leri de gönder
    options.AddPolicy("Frontend", policy => policy
        .SetIsOriginAllowed(_ => true)
        .AllowAnyMethod()
        .AllowAnyHeader()
        .AllowCredentials());
});

Bu policy gelen her Origin değerini cevaba geri yazar ve credential'a izin verir. Kullanıcı Bookstore'a giriş yapmışken başka bir sekmede açtığı herhangi bir site, onun cookie'siyle API'ye istek atıp cevabı okuyabilir. Cookie'nin SameSite=Lax olması bunu çoğu durumda engeller, çünkü tarayıcı başka bir siteden atılan fetch'e Lax cookie eklemez. Ama frontend ile API farklı sitelerdeyse cookie genelde SameSite=None olur ve bu koruma da gider.

CORS Policy'sini Doğru Kurmak

Doğru policy origin'leri tek tek listeler, method ve header'ları da kullandıklarınla sınırlar:

builder.Services.AddCors(options =>
{
    options.AddPolicy("Frontend", policy => policy
        .WithOrigins("http://localhost:4200")
        .WithMethods("GET", "POST")
        .WithHeaders("Content-Type")
        .AllowCredentials());
});

var app = builder.Build();

app.UseCors("Frontend");
app.UseAuthentication();
app.UseAuthorization();

Bir önceki yazıda UseAuthentication ve UseAuthorization'ı yazmamıştık, çünkü WebApplication onları kendisi ekliyor. Ama kendiliğinden eklenenler bizim UseCors'umuzdan önceye yerleşiyor ve tarayıcının cookie'siz gönderdiği preflight (OPTIONS) isteği fallback policy'ye takılıyor. Üç satırı yazmadan önceki cevap:

$ curl -i -X OPTIONS http://localhost:5106/books/1/cover -H "Origin: http://localhost:4200" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type"
HTTP/1.1 401 Unauthorized
Content-Length: 0
Date: Fri, 06 Nov 2026 19:25:23 GMT
Server: Kestrel

Sıralamayı açıkça yazınca aynı preflight 204 ve doğru header'larla dönüyor. Şimdi Alan'ın cookie'siyle, biri güvendiğimiz, biri tanımadığımız iki origin'den:

$ curl -i -b alan.txt http://localhost:5106/me -H "Origin: http://localhost:4200"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:18:22 GMT
Server: Kestrel
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: http://localhost:4200
Transfer-Encoding: chunked

{"id":"2","name":"alan"}

$ curl -i -b alan.txt http://localhost:5106/me -H "Origin: http://untrusted.test"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:18:22 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"id":"2","name":"alan"}

$ curl -i -X OPTIONS http://localhost:5106/books/1/cover -H "Origin: http://untrusted.test" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type"
HTTP/1.1 204 No Content
Date: Fri, 06 Nov 2026 19:18:22 GMT
Server: Kestrel

İkinci cevaba iyi bak: sunucu isteği işledi ve gövdeyi gönderdi, eksik olan yalnız Access-Control-Allow-Origin. Engeli koyan sunucu değil, tarayıcı. Tanımadığımız origin'in preflight'ı da header'sız 204 aldı; tarayıcı bu durumda asıl POST'u göndermez. Bunu gerçek tarayıcıda görmek için 5108'de küçük bir statik sayfa açıp http://127.0.0.1:5108 origin'inden /books'a fetch attım ve sayfayı headless Chrome ile çalıştırdım:

"Access to fetch at 'http://localhost:5106/books' from origin 'http://127.0.0.1:5108' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource."

Yani CORS bir yetkilendirme mekanizması değil; curl ya da başka bir sunucu onu hiç umursamaz, endpoint'in kendi authorization kontrolü her zaman gerekir. CORS isteğin sunucuya ulaşmasını da her zaman engellemez: basit bir form POST'u preflight'sız gider. Bu CSRF'in konusu, A07 yazılarında ele alacağız.

SSRF: Sunucunun Ağından Gelen İstek

Admin, kitabın kapak resmini bir URL'den çektirmek istiyor. İlk akla gelen kod:

app.MapPost("/books/{id:int}/cover", async (int id, CoverRequest request, IHttpClientFactory httpClientFactory) =>
{
    HttpClient http = httpClientFactory.CreateClient();
    // URL'yi kim verdiyse sunucu oraya istek atıyor
    byte[] image = await http.GetByteArrayAsync(request.Url);
    return Results.Ok(new { bookId = id, size = image.Length });
}).RequireAuthorization("AdminOnly");

Bu kod, istemciye sunucunun ağ konumundan istediği adrese istek attırma yetkisi veriyor. Adı SSRF (Server-Side Request Forgery, sunucu taraflı istek sahteciliği). 2021'de kendi başına A10'du; 2025'te CWE-918 olarak A01'e katıldı, çünkü özünde bu da erişim kontrolü ihlali: erişemediğin bir kaynağa, erişebilen sunucunun eliyle ulaşmak.

Risk, sunucunun görüp internetin görmediği adreslerde: yalnız localhost'u dinleyen bir yönetim paneli, kimlik doğrulamasız bir iç servis ya da bulut sağlayıcıların 169.254.169.254 gibi link-local bir adresten sunduğu, bazı ortamlarda geçici erişim bilgileri de dönen metadata servisi. Bunları burada simüle etmiyorum; önemli olan, URL'yi istemciye bıraktığın anda hepsinin menzile girmesi. Endpoint'in yalnız admin'e açık olması riski yalnız daraltır; ele geçirilmiş bir admin hesabı aynı kapıdan geçer.

Gelen URL'ye bakmak da yetmez. HttpClient yönlendirmeleri varsayılan olarak takip eder; izinli bir host'un 302'si seni izinsiz bir adrese götürebilir. Host adı da kontrol anında bir IP'ye, bağlantı anında başka bir IP'ye çözülebilir (DNS rebinding). Savunma bu yüzden katmanlı.

HttpClient'ı SSRF'e Karşı Sertleştirmek

İndirmeyi CoverFetcher adında typed bir client'a taşıyoruz. İlk katman: yalnız mutlak https URL'leri ve allowlist'teki host'lar. Allowlist appsettings.json'da "Covers": { "AllowedHosts": [ "images.bookstore.test" ] } olarak duruyor.

using System.Net;
using System.Net.Sockets;

public sealed record CoverSettings(string[] AllowedHosts);

public sealed class CoverRejectedException(string message) : Exception(message);

public sealed class CoverFetcher(HttpClient http, CoverSettings settings)
{
    public async Task<byte[]> DownloadAsync(string url, CancellationToken cancellationToken)
    {
        if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) || uri.Scheme != Uri.UriSchemeHttps)
        {
            throw new CoverRejectedException("Only absolute https URLs are accepted.");
        }
        if (!settings.AllowedHosts.Contains(uri.IdnHost, StringComparer.OrdinalIgnoreCase))
        {
            throw new CoverRejectedException($"Host '{uri.IdnHost}' is not on the allowlist.");
        }

        try
        {
            using HttpResponseMessage response = await http.GetAsync(uri, cancellationToken);
            // 3xx dahil 200 dışındaki her cevap reddedilir; yönlendirme takip edilmez
            if (response.StatusCode != HttpStatusCode.OK)
            {
                throw new CoverRejectedException($"Upstream returned {(int)response.StatusCode}.");
            }
            if (response.Content.Headers.ContentType?.MediaType is not ("image/jpeg" or "image/png"))
            {
                throw new CoverRejectedException("Only JPEG and PNG images are accepted.");
            }
            return await response.Content.ReadAsByteArrayAsync(cancellationToken);
        }
        catch (HttpRequestException ex) when (ex.InnerException is CoverRejectedException rejected)
        {
            throw rejected;
        }
    }
}

İkinci katman bağlantı düzeyinde. HttpClient'ın yerleşik bir SSRF koruması yok, ama SocketsHttpHandler.ConnectCallback ile TCP bağlantısını kendimiz açabiliyoruz. Callback host adını çözer, adreslerden biri bile engelli bir ağdaysa reddeder, değilse tam olarak kontrol ettiği adreslere bağlanır:

    public static async ValueTask<Stream> ConnectToPublicAddressAsync(
        SocketsHttpConnectionContext context, CancellationToken cancellationToken)
    {
        IPAddress[] addresses = await Dns.GetHostAddressesAsync(context.DnsEndPoint.Host, cancellationToken);
        // Adreslerden biri bile iç ağa çıkıyorsa hiç bağlanma
        if (addresses.Length == 0 || addresses.Any(IsBlocked))
        {
            throw new CoverRejectedException($"Host '{context.DnsEndPoint.Host}' resolves to a non-public address.");
        }

        Socket socket = new(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
        try
        {
            // Kontrol ettiğimiz adreslere bağlan; ikinci bir DNS sorgusu yok
            await socket.ConnectAsync(addresses, context.DnsEndPoint.Port, cancellationToken);
            return new NetworkStream(socket, ownsSocket: true);
        }
        catch
        {
            socket.Dispose();
            throw;
        }
    }

    // Lab için kısa liste: loopback, özel ağlar, link-local (bulut metadata adresi dahil)
    private static readonly IPNetwork[] BlockedNetworks =
    [
        IPNetwork.Parse("0.0.0.0/8"), IPNetwork.Parse("10.0.0.0/8"), IPNetwork.Parse("100.64.0.0/10"),
        IPNetwork.Parse("127.0.0.0/8"), IPNetwork.Parse("169.254.0.0/16"), IPNetwork.Parse("172.16.0.0/12"),
        IPNetwork.Parse("192.168.0.0/16"), IPNetwork.Parse("::1/128"), IPNetwork.Parse("fc00::/7"),
        IPNetwork.Parse("fe80::/10")
    ];

    private static bool IsBlocked(IPAddress address)
    {
        if (address.IsIPv4MappedToIPv6)
        {
            address = address.MapToIPv4();
        }
        return BlockedNetworks.Any(network => network.Contains(address));
    }

İkinci bir DNS sorgusu yapmamak, kontrol ile bağlantı arasındaki rebinding penceresini kapatıyor. IPNetwork, .NET 8'den beri System.Net'te; CIDR aralığını parse edip Contains ile kontrol ediyor. IPv6 içine gömülü IPv4 adreslerini MapToIPv4 ile açıyoruz. Liste lab için kısa; gerçek projede IANA'nın özel amaçlı adres kayıtlarına göre genişlet. Handler'ı typed client'a bağlayalım:

builder.Services.AddSingleton(new CoverSettings(
    builder.Configuration.GetSection("Covers:AllowedHosts").Get<string[]>() ?? []));
builder.Services.AddHttpClient<CoverFetcher>(client =>
    {
        client.Timeout = TimeSpan.FromSeconds(10);
        client.MaxResponseContentBufferSize = 2 * 1024 * 1024;
    })
    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
    {
        AllowAutoRedirect = false,
        UseProxy = false,
        ConnectCallback = CoverFetcher.ConnectToPublicAddressAsync
    });

AllowAutoRedirect = false ile 3xx cevabı olduğu gibi dönüyor ve DownloadAsync 200 dışındakileri reddediyor. UseProxy = false, sistemde proxy tanımlıysa callback'in proxy'yi değil hedefi görmesi için. Timeout ve 2 MB sınırı sunucunun yavaş ya da dev bir cevapla meşgul edilmesini önlüyor. Endpoint uzak cevabın gövdesini asla istemciye aktarmıyor:

app.MapPost("/books/{id:int}/cover", async (int id, CoverRequest request, CoverFetcher covers,
    CancellationToken cancellationToken) =>
{
    try
    {
        byte[] image = await covers.DownloadAsync(request.Url, cancellationToken);
        return Results.Ok(new { bookId = id, size = image.Length });
    }
    catch (CoverRejectedException ex)
    {
        return Results.Problem(ex.Message, statusCode: StatusCodes.Status400BadRequest);
    }
    catch (HttpRequestException)
    {
        return Results.Problem("Cover could not be downloaded.", statusCode: StatusCodes.Status502BadGateway);
    }
}).RequireAuthorization("AdminOnly");

Ada'nın cookie'siyle iç adresleri deneyelim. İlk ikisi birinci katmana takılıyor:

$ curl -i -b ada.txt -X POST http://localhost:5106/books/1/cover -H "Content-Type: application/json" -d '{"url":"http://localhost:5106/admin/users"}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Date: Fri, 06 Nov 2026 19:19:17 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"Only absolute https URLs are accepted."}

$ curl -i -b ada.txt -X POST http://localhost:5106/books/1/cover -H "Content-Type: application/json" -d '{"url":"https://localhost:5107/admin/users"}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Date: Fri, 06 Nov 2026 19:19:17 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"Host 'localhost' is not on the allowlist."}

İkinci katmanı görmek için birinin iç ağa çözülen bir host'u yanlışlıkla allowlist'e eklediğini varsayalım ve uygulamayı localhost ekli başlatalım. Configuration'da array'ler index'e göre birleştiği için __1; __0 mevcut değerin üzerine yazardı:

$ dotnet run --launch-profile http -e Covers__AllowedHosts__1=localhost
...
$ curl -i -b ada.txt -X POST http://localhost:5106/books/1/cover -H "Content-Type: application/json" -d '{"url":"https://localhost:5107/admin/users"}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Date: Fri, 06 Nov 2026 19:19:20 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"Host 'localhost' resolves to a non-public address."}

Host allowlist'ten geçti ama localhost ::1 ve 127.0.0.1'e çözüldüğü için bağlantı hiç açılmadı. Asıl savunma allowlist, IP kontrolü onun arkasındaki emniyet kemeri; yalnız IP engelleme listesine güvenmek zayıf, çünkü neyin "iç" sayılacağı ortama göre değişir. Ağ katmanındaki egress kuralları da bu ikisinin üstüne eklenir.

Sık Düşülen Tuzaklar

Path traversal'da en sık hata, kanonik yol yerine string'de .. aramak: mutlak yolları kaçırır, a..b.txt gibi masum adları reddeder. Kanonik yolu, sonunda ayraç olan kök yolla karşılaştır.

CORS'ta ilk tuzak onu bir güvenlik katmanı sanmak; CORS koruma eklemez, tarayıcının koruduğu sınırı gevşetir. İkincisi, origin kontrolünü elle string işlemleriyle yazmak: SetIsOriginAllowed(origin => origin.EndsWith("bookstore.test")), aynı son ekle biten başka bir alan adını da kabul eder. Origin'leri tam değerleriyle, ortam bazında yapılandırmadan oku.

SSRF'te tuzak, kontrolü host adının yazılışına yapmak: localhost string'ini engellemek gibi. Aynı adrese çıkan başka yazımlar hep vardır; karar bağlanmadan hemen önce, çözülmüş IP üzerinden verilmeli. Uzak cevabı ya da hata ayrıntısını istemciye aynen dönmek de iç ağı görmeyen birine onun içini anlatmak demek.

Kontrol Listesi

İstemciden gelen değeri dosya yolunda kullanan her endpoint kanonik yolu kök klasörle karşılaştırıyor mu, daha iyisi dosyayı sunucuda mı belirliyor? CORS policy'si origin'leri tek tek listeliyor mu, origin'i olduğu gibi geri yazan bir kod var mı? UseCors authentication ve authorization'dan önce mi? Sunucunun dışarıya istek attığı her yerde host allowlist'i, kapalı yönlendirme, çözülmüş IP kontrolü, timeout ve boyut sınırı var mı?

Küçük Bir Deneme

★☆☆ İndirme endpoint'ine Path.GetFileName(name) != name kontrolü ekleyip 400 dön. ../appsettings.json ve sub/../clean-code-sample.txt için ne değişti? İkincisi meşru bir istek miydi?

★★☆ CORS policy'sindeki origin listesini appsettings.json'dan oku: Development'ta http://localhost:4200, Production'da kendi alan adın. curl ile Origin header'ını değiştirerek iki ortamı da dene.

★★★ CoverFetcher'a bir boyut kontrolü ekle: cevaptaki Content-Length 2 MB'ı aşıyorsa gövdeyi okumadan reddet. Bu kontrol ile MaxResponseContentBufferSize arasındaki fark ne, Content-Length hiç gönderilmezse hangisi seni korur?

Bir sonraki yazıda A02:2025 Security Misconfiguration'a geçiyoruz. Production'da açık kalan developer exception page, herkese açık bir OpenAPI dokümanı, ASP.NET Core'da yerleşik olmayan CSP için küçük bir middleware, HSTS'in localhost'ta neden görünmediği ve XML okurken DTD'yi kapalı tutmak.