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

API5 BFLA: Endpoint Envanterinden Otomatik Yetkilendirme Testi ve Admin API'lerini Ayırmak

Mert Özen 8 Kas 2026 11 dk 2 görüntülenme
API5 BFLA: Endpoint Envanterinden Otomatik Yetkilendirme Testi ve Admin API'lerini Ayırmak

BFLA'yı, uygulamanın gerçekten yayımladığı endpoint'leri EndpointDataSource ile listeleyip her birinin açık bir yetki kararı taşıdığını ve matriste satırı olduğunu doğrulayan testlerle kalıcı olarak kapatırsın. Admin yüzeyini route group, policy ve ayrı port'la ayırıyor, RequireHost'un Host header'ıyla aşılabildiğini gösterip gerçek port kontrolü ekliyor, method bazlı yetkiyi ve method override header'larını ele alıyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ve EF (Entity Framework) Core 10.0.14 ile derlenip denenmiştir.

Önce Tahmin Et

Lab beş yazıdır büyüyor: siparişler, faturalar, API (Application Programming Interface) key'leri, raporlar, SMS (Short Message Service) bildirimleri. Şu an kaç endpoint var ve her birinin kim tarafından çağrılabileceğini bir tabloda gösterebilir misin? Daha zor soru: bu tabloyu yarın biri yeni bir endpoint eklediğinde de doğru tutan şey ne?

Ben bu yazıya başlamadan önce sayamadım. Kodda Map çağrılarını aradım, grupların ön eklerini birleştirdim ve iki endpoint'in matriste hiç satırı olmadığını, beşinin ise yalnız fallback policy sayesinde korunduğunu fark ettim. Bunu bulmak için bir test yazdım ve test de aynısını söyledi.

Bu yazı API5'in (OWASP API Security Top 10 2023'ün beşinci maddesi) konusu: BFLA (Broken Function Level Authorization). Fallback policy'nin ve rol policy'lerinin temeli Web Güvenliği (OWASP Top 10) serisinin A01 Broken Access Control yazısında. Burada o temelin üstüne kalıcı ve otomatik bir koruma kuruyoruz: uygulamanın gerçekten yayımladığı endpoint'lerin envanteri, her endpoint için açık bir yetki kararı, matrisle envanter arasında iki yönlü kontrol ve admin yüzeyinin ayrı bir port'a taşınması.

BFLA'nın Kaynağı: Unutulan Endpoint

OWASP'ın (Open Worldwide Application Security Project) API5 sayfası tipik soruları sıralıyor: sıradan bir kullanıcı admin endpoint'lerine erişebiliyor mu, HTTP (Hypertext Transfer Protocol) method'unu GET'ten DELETE'e çevirerek yetkisi olmayan bir işlem yapabiliyor mu, başka bir gruba ait işlevi URL'yi (Uniform Resource Locator) tahmin ederek çağırabiliyor mu? Üçünün de ortak noktası, yetkilendirmenin "endpoint başına" bir karar olması ve kararın bazı endpoint'lerde hiç verilmemiş ya da yanlış verilmiş olması.

BOLA'da (Broken Object Level Authorization) sorun "hangi kayıt" sorusuydu ve çözümü veri katmanına taşıdık. BFLA'da sorun "hangi işlev" sorusu ve çözüm endpoint metadata'sında. ASP.NET Core bu metadata'yı zaten tutuyor: her endpoint'in route'u, HTTP method'ları, authorization ve anonymous attribute'ları EndpointDataSource üzerinden okunabiliyor. Envanteri elle tutmak yerine uygulamanın kendisinden alabiliriz.

Endpoint Envanteri: EndpointDataSource

Test, WebApplicationFactory'nin kurduğu uygulamadan bütün route endpoint'lerini alıp matristeki biçime çeviriyor:

// Uygulamanın gerçekten yayımladığı endpoint'ler: "GET /orders/{orderId}" biçiminde
private IReadOnlyList<(string Endpoint, EndpointMetadataCollection Metadata)> Inventory() =>
    factory.Services.GetRequiredService<EndpointDataSource>().Endpoints
        .OfType<RouteEndpoint>()
        .SelectMany(e => (e.Metadata.GetMetadata<IHttpMethodMetadata>()?.HttpMethods ?? ["ANY"])
            .Select(method => ($"{method} {Normalize(e.RoutePattern.RawText!)}", e.Metadata)))
        .ToList();

// Route constraint'lerini ve sondaki "/" karakterini atar: "/orders/{orderId:guid}" -> "/orders/{orderId}"
private static string Normalize(string route)
{
    string withoutConstraints = RouteConstraint().Replace(route, "{$1}");
    return withoutConstraints.Length > 1 ? withoutConstraints.TrimEnd('/') : withoutConstraints;
}

[GeneratedRegex(@"\{(\w+)(:[^}]+)?\}")]
private static partial Regex RouteConstraint();

Burada ince bir nokta var: envanter test host'unda koşan gerçek uygulamadan geliyor, kaynak kodu taramaktan değil. MapOpenApi() gibi yalnız Development'ta eklenen endpoint'ler, bir NuGet paketinin eklediği endpoint'ler ya da bir extension method'un içinde unutulmuş bir MapGet de listede görünüyor. Kod taraması bunların hepsini kaçırabilir.

Kural 1: Her Endpoint Açık Bir Karar Taşır

Fallback policy, hiçbir authorization metadata'sı olmayan endpoint'lere authentication şartı koyuyor. Bu iyi bir güvenlik ağı ama bir niyet beyanı değil: endpoint'i yazan kişinin "bu endpoint herkese açık olmalı", "giriş yapan herkese açık olmalı" ya da "yalnız admin" kararını verdiğini göstermiyor. İlk kural, her endpoint'in ya bir IAuthorizeData (bir policy, rol ya da RequireAuthorization()) ya da açık bir IAllowAnonymous taşıması:

[Fact]
public void Every_endpoint_declares_an_explicit_authorization_decision()
{
    // Fallback policy bir güvenlik ağı; niyet her endpoint'te açıkça yazılı olmalı
    string[] implicitOnly = Inventory()
        .Where(e => !e.Metadata.Any(m => m is IAuthorizeData or IAllowAnonymous))
        .Select(e => e.Endpoint)
        .Order()
        .ToArray();

    Assert.True(implicitOnly.Length == 0, $"Only the fallback policy protects: {string.Join(", ", implicitOnly)}");
}

Testi bir önceki yazının kodu üzerinde ilk kez koşturduğumda:

failed EndpointInventoryTests.Every_endpoint_declares_an_explicit_authorization_decision (635ms)
  Only the fallback policy protects: GET /orders, GET /orders/{orderId}, GET /orders/{orderId}/invoices/{invoiceId}, PATCH /orders/{orderId}, POST /orders/{orderId}/cancel

Beş endpoint'in hepsi /orders grubunda ve hepsi doğru çalışıyordu, çünkü fallback policy authentication istiyordu. Ama biri yarın SetFallbackPolicy satırını "Swagger'da sorun çıkarıyor" diye silerse, bu beş endpoint sessizce anonim hâle gelir. Düzeltme tek satır, grup seviyesinde:

// Grup seviyesinde açık karar: buradaki her endpoint en az authentication ister
RouteGroupBuilder orders = app.MapGroup("/orders").RequireAuthorization();

Kural 2: Her Endpoint Matriste, Matristeki Her Satır Uygulamada

İkinci kural, ilk yazıdaki matrisi envanterle bağlıyor. Uygulamadaki her endpoint matriste en az bir satırla görünmeli; matristeki her endpoint de uygulamada var olmalı. İkinci yön, silinen ya da yeniden adlandırılan bir endpoint'in matriste "yeşil ama anlamsız" satırlar bırakmasını engelliyor.

[Fact]
public void Every_endpoint_is_in_the_authorization_matrix()
{
    HashSet<string> inMatrix = AuthorizationMatrix.Load().Select(r => $"{r.Method} {r.Route}").ToHashSet();

    string[] missing = Inventory().Select(e => e.Endpoint).Where(e => !inMatrix.Contains(e)).Order().ToArray();

    Assert.True(missing.Length == 0, $"Not in the matrix: {string.Join(", ", missing)}");
}

[Fact]
public void Matrix_has_no_rows_for_endpoints_that_do_not_exist()
{
    HashSet<string> inventory = Inventory().Select(e => e.Endpoint).ToHashSet();

    string[] stale = AuthorizationMatrix.Load().Select(r => $"{r.Method} {r.Route}").Distinct()
        .Where(e => !inventory.Contains(e)).Order().ToArray();

    Assert.True(stale.Length == 0, $"Matrix rows without an endpoint: {string.Join(", ", stale)}");
}

İlk koşu:

failed EndpointInventoryTests.Every_endpoint_is_in_the_authorization_matrix (0ms)
  Not in the matrix: DELETE /admin/api-keys/{id}, GET /health, GET /openapi/{documentName}.json, POST /account/password-reset, POST /admin/api-keys, POST /integrations/orders/{orderId}/shipments

Bu listedeki endpoint'lerin çoğu için test yazmıştık; API key'leri ve imzalı istekler üçüncü yazıda ayrıntılı test edilmişti. Ama matris, "hangi aktör hangi endpoint'te ne alır?" sorusunun tek yeri olmalı ve bu altı endpoint orada yoktu. Eksik satırları ekledim: anonim, yanlış rol ve doğru rol için en az birer satır. GET /health ve GET /openapi/{documentName}.json için tek satır yeterli: anonim istek 200 almalı; bu bilinçli bir karar ve şimdi matriste yazılı. Production'da OpenAPI dokümanının kapalı olması ise API8 (Security Misconfiguration) yazısının konusu.

Artık yeni bir endpoint eklemenin bir bedeli var: endpoint'i yazan kişi aynı pull request'te matrise satır eklemek zorunda, yoksa build kırılıyor. Bu bedel bilinçli. İkinci yazıdaki generator da bu satırlardan başka tenant senaryolarını otomatik türetiyor.

Admin API'lerini Ayırmak

BFLA'nın en sık görüldüğü yer admin endpoint'leri, çünkü yetki kararı bir path ön ekine bağlı ve o ön ek tahmin edilebilir. İlk savunma bütün admin yüzeyini tek bir route group'ta toplamak: policy bir kez yazılır ve gruba eklenen her endpoint onu miras alır. İkinci savunma admin yüzeyini ayrı bir port'a taşımak. Lab artık iki port'ta dinliyor: 19516 herkese açık API, 19526 yalnız admin.

// Admin yüzeyi tek bir grupta: policy, host ve port kısıtı bütün alt endpoint'lere uygulanır
public static RouteGroupBuilder MapAdminGroup(this IEndpointRouteBuilder app, int adminPort) =>
    app.MapGroup("/admin")
        .RequireAuthorization(LabPolicies.TenantAdmin)
        .RequireHost($"*:{adminPort}")
        .AddEndpointFilter(new AdminPortFilter(adminPort));

İlk sürümde yalnız RequireHost("*:19526") vardı ve çalışıyor gibi görünüyordu: public port'tan /admin/users 404 dönüyordu. Sonra şunu denedim:

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:19516/admin/users -H "Host: localhost:19526" -H "Authorization: Bearer $ADA"
200

RequireHost isteğin geldiği bağlantıya değil, Host header'ına bakıyor ve o header'ı istemci yazıyor. Public port'a gelen bir istek Host: localhost:19526 diyerek admin endpoint'ine eşleşti. Yani RequireHost bir routing aracı, bir güvenlik sınırı değil. Gerçek sınır için isteğin hangi yerel port'tan kabul edildiğine bakan küçük bir filter ekledim:

// RequireHost Host header'ına bakar ve o header'ı istemci yazar; isteğin gerçekten geldiği port'u ayrıca kontrol et
public sealed class AdminPortFilter(int adminPort) : IEndpointFilter
{
    public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) =>
        context.HttpContext.Connection.LocalPort == adminPort
            ? next(context)
            : ValueTask.FromResult<object?>(Results.NotFound());
}

Aynı iki istek, önce public port'a sahte Host header'ıyla, sonra admin port'a:

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:19516/admin/users -H "Host: localhost:19526" -H "Authorization: Bearer $ADA"
404
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:19526/admin/users -H "Host: localhost:19526" -H "Authorization: Bearer $ADA"
200
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:19526/admin/users -H "Authorization: Bearer $ALAN"
403

Admin port'unda bile yetki kararı değişmedi: Alan 403 alıyor. Port ayrımı yetkilendirmenin yerine geçmiyor, ona bir katman ekliyor. Asıl değer ağ seviyesinde geliyor: admin port'unu yalnız iç ağa ya da VPN'e (Virtual Private Network) açık bir arayüze bağlayabilir, public load balancer'a hiç eklemeyebilirsin. O zaman internetteki bir istemci admin endpoint'lerinin varlığını bile göremez.

Test tarafında küçük bir zorluk var: TestServer gerçek bir bağlantı açmıyor ve LocalPort admin port'unu göstermiyor; filter'ı ekler eklemez test host'undaki bütün admin istekleri 404 almaya başladı. Factory'ye, test istemcisinin base address'indeki port'u LocalPort'a yazan bir IStartupFilter ekledim; admin satırları matriste http://localhost:19526 base address'iyle koşuyor. Sahte Host header senaryosu TestServer'da üretilemediği için filter'ı ayrıca bir unit test'le koruyorum:

[Fact]
public async Task Admin_port_filter_ignores_a_spoofed_host_header()
{
    DefaultHttpContext context = new();
    context.Connection.LocalPort = 19516;
    context.Request.Host = new HostString("localhost", 19526);
    bool reachedEndpoint = false;

    object? result = await new AdminPortFilter(19526).InvokeAsync(
        new DefaultEndpointFilterInvocationContext(context),
        _ => { reachedEndpoint = true; return ValueTask.FromResult<object?>(Results.Ok()); });

    Assert.False(reachedEndpoint);
    Assert.IsType<NotFound>(result);
}

Method'a Göre Yetki ve Method Override Header'ları

REST (Representational State Transfer) API'lerde aynı route'u farklı method'larla kullanmak olağan: GET /books/{bookId} kitabı okur, DELETE /books/{bookId} siler. Burada BFLA'nın klasik hâli ortaya çıkıyor: biri GET'i yazarken yetkiyi düşünür, DELETE sonradan eklenir ve grubun varsayılan kuralını miras alır.

// Aynı route, farklı method, farklı yetki: GET herkese, DELETE yalnız tenant admin'e
books.MapDelete("/{bookId:guid}", async (Guid bookId, OrdersDbContext db, CancellationToken ct) =>
    await db.Books.Where(b => b.Id == bookId).ExecuteDeleteAsync(ct) == 1
        ? Results.NoContent()
        : Results.NotFound())
    .RequireAuthorization(LabPolicies.TenantAdmin);

Envanter testi burada da işe yarıyor, çünkü method'u envanter anahtarının bir parçası yaptık: GET /books/{bookId} ve DELETE /books/{bookId} matriste ayrı satırlar. DELETE için anonim, müşteri, support, başka tenant'ın admin'i ve doğru admin satırları var; sonuncusu silinmesi için ayrılmış retired-book kaydını kullanıyor, böylece diğer testlerin kullandığı kitap yerinde kalıyor.

Method'la ilgili bir tuzak daha var: method override header'ları. Bazı framework'ler ve gateway'ler, yalnız GET ve POST gönderebilen eski istemciler için X-HTTP-Method-Override: DELETE gibi bir header'ı okuyup isteği DELETE olarak işler. ASP.NET Core bunu yalnız UseHttpMethodOverride() middleware'ini eklersen yapar; lab'da yok. Peki neden yine de önlem alıyoruz? Önümüzde bu header'ı yorumlayan bir proxy varsa, proxy isteği DELETE olarak görür ve kendi kurallarını ona göre uygular; uygulama ise POST görür. Yetki kararı iki yerde farklı method üzerinden verilir. En basit çözüm, bu header'ları taşıyan isteği hiç kabul etmemek:

// Uygulama method override kullanmıyor; önündeki bir proxy kullanırsa yetki kararı iki yerde farklılaşır
public sealed class MethodOverrideGuard(RequestDelegate next, ILogger<MethodOverrideGuard> logger)
{
    private static readonly string[] OverrideHeaders = ["X-HTTP-Method-Override", "X-HTTP-Method", "X-Method-Override"];

    public Task InvokeAsync(HttpContext context)
    {
        string? header = OverrideHeaders.FirstOrDefault(context.Request.Headers.ContainsKey);
        if (header is null)
        {
            return next(context);
        }

        logger.LogWarning("Rejected {Method} {Path} carrying {Header}", context.Request.Method,
            context.Request.Path, header);
        context.Response.StatusCode = StatusCodes.Status400BadRequest;
        return Task.CompletedTask;
    }
}
$ curl -s -o /dev/null -w "%{http_code}\n" -X DELETE http://localhost:19516/books/0197f003-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN"
403
$ curl -i -X POST http://localhost:19516/books/0197f003-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN" -H "X-HTTP-Method-Override: DELETE"
HTTP/1.1 400 Bad Request
Content-Length: 0
Date: Sun, 08 Nov 2026 06:02:29 GMT
Server: Kestrel

$ curl -i -X POST http://localhost:19516/books/0197f003-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN"
HTTP/1.1 405 Method Not Allowed
Content-Length: 0
Date: Sun, 08 Nov 2026 06:02:29 GMT
Server: Kestrel
Allow: DELETE, GET

Son yanıttaki Allow: DELETE, GET header'ı ilginç: 405 yanıtı route'ta hangi method'ların olduğunu söylüyor. Bu ASP.NET Core'un standart davranışı ve RFC (Request for Comments) 9110 de 405 yanıtında Allow header'ını zorunlu tutuyor. Gizlemeye çalışmak yerine, o method'ların her birinin kendi yetki kararını taşıdığından emin olmak doğru yaklaşım; envanter testi tam olarak bunu yapıyor.

Üretimde Dikkat

Admin port'unu ayırmak, ancak ağ tarafı da ayrılırsa anlam kazanıyor. Kestrel'de admin endpoint'ini 127.0.0.1 ya da iç ağ arayüzüne, public endpoint'i ise dış arayüze bağla; load balancer'a yalnız public port'u ekle. Container ortamında admin port'unu servis olarak yayımlama. Daha güçlü bir ayrım istiyorsan admin API'sini ayrı bir uygulama olarak, ayrı kimlik doğrulama ayarlarıyla (örneğin yalnız iç kimlik sağlayıcının token'larını kabul eden) deploy et.

Envanter testinin koştuğu ortam önemli. Test Development ortamında koşuyor ve MapOpenApi() envantere giriyor. Production'da bu endpoint olmayacak; iki ortam arasındaki fark API9 (Improper Inventory Management) yazısında "envanter" sorusunun bir parçası olarak geri gelecek.

AllowedHosts ayarı ve Host filtering middleware'i de bir Host header savunması, ama amacı farklı: tanımadığın host adlarıyla gelen istekleri reddetmek. RequireHost gibi o da header'a bakıyor ve port ayrımı yapmıyor. Hangi port'tan bağlantı kabul edildiğini yalnız Connection.LocalPort gerçekten biliyor; reverse proxy arkasındaysan bu değer proxy'nin bağlandığı port olur, bu yüzden ayrımı proxy'de de yapman gerekir.

Matris büyüdükçe okunabilirliği korumak için satırları endpoint'e göre gruplu tut ve her grubun başında bir yorum satırıyla endpoint'in amacını yaz. CSV (Comma-Separated Values) dosyasında # ile başlayan satırlar zaten yok sayılıyor.

Sık Düşülen Tuzaklar

Birinci tuzak, admin kontrolünü istemciye bırakmak. Angular'da admin menüsünü gizlemek bir kullanıcı deneyimi kararı. Endpoint'in kendisi policy taşımıyorsa, URL'yi bilen herkes onu çağırabilir.

İkincisi, yetkiyi path'e göre bir middleware'de kontrol etmek. if (path.StartsWith("/admin")) gibi bir kontrol /Admin/users, //admin/users ya da URL encoding'li varyasyonlarda farklı davranabilir. Yetkiyi endpoint metadata'sına bağlamak, routing'in eşleştirdiği şeyin ta kendisi üzerinde karar vermek demek.

Üçüncüsü, yeni bir method'u mevcut bir route'a eklerken grubun varsayılan policy'sine güvenmek. /books grubunun varsayılanı "giriş yapan herkes"; DELETE eklerken açıkça admin policy'si yazmasaydık müşteriler kitap silebilirdi. Envanter testi bunu yakalamaz, çünkü endpoint bir karar taşıyor; matristeki alan, DELETE, 403 satırı yakalar.

Dördüncüsü, RequireHost'u güvenlik sınırı sanmak. Gördüğümüz gibi Host header'ı istemcinin kontrolünde.

Küçük Bir Deneme

★☆☆ Lab'a GET /orders/{orderId}/items endpoint'ini ekle ve testleri koştur. Hangi test kırıldı ve mesajı sana ne söyledi?

★★☆ SetFallbackPolicy satırını sil ve testleri tekrar koştur. Envanter testi hâlâ yeşil mi? Matriste kırmızıya dönen satır var mı? Fallback policy'nin hangi senaryoda hâlâ işe yaradığını düşün.

★★★ Envanter testine üçüncü bir kural ekle: matristeki her endpoint için en az bir reddedilen (401, 403 ya da 404) ve en az bir izin verilen (2xx) satır olmalı. GET /health gibi istisnaları nasıl işaretlersin?

Bir sonraki yazıda API6'ya (Unrestricted Access to Sensitive Business Flows) geçiyoruz: hangi iş akışlarının hassas olduğunu belirlemek, Idempotency-Key header'ı ile tekrarlanan POST'ları tek kez işlemek ve tüm API için tek bir rate limit yerine akış bazlı limitler.