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

API8 ve API9: Yanlış Yapılandırma ve Envanter — OpenAPI'yi Envanter Olarak Kullanmak, Deprecation/Sunset Header'ları ve Shadow API'ler

Mert Özen 8 Kas 2026 11 dk 2 görüntülenme
API8 ve API9: Yanlış Yapılandırma ve Envanter — OpenAPI'yi Envanter Olarak Kullanmak, Deprecation/Sunset Header'ları ve Shadow API'ler

API8'e karşı API'ye özgü bir kontrol listesini Production ortamında koşan testlere bağlarsın; API9'a karşı build sırasında üretilen OpenAPI dokümanını commit edilen bir envanter olarak kullanır ve eski sürümleri RFC 9745 Deprecation ve RFC 8594 Sunset header'larıyla emekliye ayırırsın. CORS ve middleware sırası, git diff ile habersiz endpoint ve saat dilimi tuzağı.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Asp.Versioning.Http 10.2.3 ve Microsoft.Extensions.ApiDescription.Server 10.0.14 ile derlenip denenmiştir.

Önce Tahmin Et

Lab'daki API (Application Programming Interface) sekiz yazıdır büyüyor ve şu an 25'ten fazla endpoint'i var. Production'a çıkmadan önce şu soruları cevaplayabilir misin: OpenAPI dokümanı internete açık mı? Bir exception olduğunda yanıtta stack trace var mı? Hangi origin'ler bu API'yi tarayıcıdan çağırabilir? Ve en zoru: geçen ay hangi endpoint'ler eklendi, hangisi hâlâ kullanılıyor, eski sürüm ne zaman kapanacak?

İlk üç soru API8'in (OWASP API Security Top 10 2023'teki adıyla Security Misconfiguration) konusu, sonuncusu API9'un (Improper Inventory Management). Genel yapılandırma hatalarını, hata sayfalarını ve header'ları Web Güvenliği (OWASP Top 10) serisinin A02 Security Misconfiguration yazısında konuşmuştuk; burada tekrar etmeyeceğim. Bu yazıda API'ye özgü bir kontrol listesi kuruyor, her maddesini Production ortamında koşan bir testle bağlıyor, sonra build sırasında üretilen OpenAPI dokümanını bir envanter olarak kullanıp eski sürümleri Deprecation ve Sunset header'larıyla emekliye ayırıyoruz.

API8 ve API9 Neden Bir Arada

İki madde aynı sorunun iki yüzü: ne yayımladığını bilmemek. API8'de yayımladığın şeyin ayarlarını bilmiyorsun: development'ta açtığın OpenAPI UI'ı (User Interface) production'da da açık kalıyor, CORS (Cross-Origin Resource Sharing) policy'si * ile geçiştiriliyor. API9'da ise yayımladığın şeyin kendisini bilmiyorsun: unutulmuş bir v1, staging'de gerçek veriyle çalışan bir kopya, "internal" diye yazılmış ama internete açık bir endpoint. İkisinin çözümü de aynı alışkanlıktan geçiyor: yapılandırmayı ve envanteri bir test ya da CI (Continuous Integration) adımıyla görünür kılmak.

API8: API'ye Özgü Kontrol Listesi, Testle

Lab için şu listeyi çıkardım. Her satırda kontrol, lab'daki savunma ve onu koruyan test var:

KontrolLab'daki savunmaTest
OpenAPI ve Scalar UI production'da kapalıMapOpenApi() yalnız Development'taOpenApi_document_is_not_served_in_production
Hata yanıtında iç ayrıntı yokProduction'da UseExceptionHandler() ve ProblemDetailsUnhandled_exception_returns_problem_details_without_internals
CORS yalnız bilinen origin'lere açıkTek policy, açık origin, method ve header listesiCors_allows_only_known_origins
Kimlik gerektiren yanıt cache'lenmezCache-Control: no-store, X-Content-Type-Options: nosniffAuthenticated_responses_are_not_cacheable_and_not_sniffable
Kullanılmayan HTTP (Hypertext Transfer Protocol) method'ları kapalıRouting'de olmayan method 405Unused_methods_are_rejected
Eski sürüm duyurulurDeprecation, Sunset ve Link header'larıDeprecated_version_announces_deprecation_and_sunset

HSTS (HTTP Strict Transport Security), CSP (Content Security Policy) ve diğer header'ların ayrıntısını ilerideki ASP.NET Core Güvenliği serisinin security header yazısına bırakıyorum; bir JSON (JavaScript Object Notation) API için en önemli ikisi burada. Yanıt header'larını ekleyen middleware:

// Kimlik doğrulaması gereken yanıtlar paylaşılan cache'lerde saklanmaz; içerik tipi tahmin edilmez
public static IApplicationBuilder UseApiResponseHeaders(this IApplicationBuilder app) => app.Use((context, next) =>
{
    context.Response.OnStarting(() =>
    {
        context.Response.Headers.XContentTypeOptions = "nosniff";
        if (context.Request.Headers.Authorization.Count > 0 && !context.Response.Headers.ContainsKey("Cache-Control"))
        {
            context.Response.Headers.CacheControl = "no-store";
        }
        return Task.CompletedTask;
    });
    return next(context);
});

CORS policy'si bilerek dar: Angular uygulamasının origin'i, kullandığı üç method ve üç header. AllowAnyOrigin() ile AllowCredentials()'ı birlikte kullanamazsın, ASP.NET Core buna izin vermiyor; ama gelen Origin değerini olduğu gibi geri yansıtan elle yazılmış bir middleware aynı sonucu sessizce üretir. CORS'un mekaniğini Web Güvenliği serisinin A01 yazısının ikinci yarısında görmüştük.

Pipeline sırası burada beni bir kez yaktı. İlk sürümde app.UseCors(...) çağrısını middleware listesinin ortasına koymuştum ve izinli origin'den gelen preflight bile Access-Control-Allow-Origin almadı. Sebebi: WebApplication, authentication ve authorization middleware'ini sen açıkça çağırmazsan pipeline'ın başına ekliyor. Preflight isteği Authorization header'ı taşımadığı için önce fallback policy'ye takılıyor, CORS middleware'ine hiç ulaşmıyordu. Çözüm sırayı açıkça yazmak:

if (!app.Environment.IsDevelopment())
{
    // Production'da exception ayrıntısı yerine yalnız ProblemDetails
    app.UseExceptionHandler();
}
app.UseApiResponseHeaders();
// Sıra önemli: preflight istekleri authorization'a takılmadan CORS'ta cevaplanmalı
app.UseCors(ApiHardening.ShopCorsPolicy);
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<MethodOverrideGuard>();
app.UseRateLimiter();
app.UseRequestTimeouts();

Production ayarlarıyla çalışan API'ye iki preflight:

$ curl -s -D - -o /dev/null -X OPTIONS http://localhost:19519/orders -H "Origin: https://evil.example" -H "Access-Control-Request-Method: GET"
HTTP/1.1 204 No Content
Date: Sun, 08 Nov 2026 06:27:50 GMT
Server: Kestrel
X-Content-Type-Options: nosniff

$ curl -s -D - -o /dev/null -X OPTIONS http://localhost:19519/orders -H "Origin: https://shop.bookstore.test" -H "Access-Control-Request-Method: GET"
HTTP/1.1 204 No Content
Date: Sun, 08 Nov 2026 06:27:50 GMT
Server: Kestrel
Access-Control-Allow-Headers: Authorization,Content-Type,Idempotency-Key
Access-Control-Allow-Methods: GET,POST,PATCH
Access-Control-Allow-Origin: https://shop.bookstore.test
X-Content-Type-Options: nosniff

İkisi de 204 alıyor, fark yalnız Access-Control-Allow-* header'larında. Tarayıcı ilkinde gerçek isteği hiç göndermeyecek.

Production Ortamında Test Etmek

Yapılandırma testlerinin en sık düştüğü tuzak, Development ortamında koşmak. WebApplicationFactory varsayılan olarak Development kullanıyor; o ortamda developer exception page açık, OpenAPI dokümanı yayında. Production davranışını test etmek için ortamı açıkça değiştiriyoruz:

// API8 kontrol listesi: testler Production ortamında koşar
public sealed class ConfigurationTests(OrdersApiFactory factory) : IClassFixture<OrdersApiFactory>
{
    private CancellationToken Ct => TestContext.Current.CancellationToken;

    private HttpClient Production(string? actor = "alan")
    {
        HttpClient client = factory.WithWebHostBuilder(b => b.UseEnvironment("Production")).CreateClient();
        if (actor is not null)
        {
            client.DefaultRequestHeaders.Add(TestAuthHandler.UserHeader, actor);
        }
        return client;
    }

    [Fact]
    public async Task Unhandled_exception_returns_problem_details_without_internals()
    {
        factory.Carrier.Respond = _ => throw new InvalidOperationException("Server=db.internal;Password=dev-only-secret");

        using HttpResponseMessage response = await Production().GetAsync($"/orders/{SeedData.AlanOrder}/shipping-quote", Ct);
        string body = await response.Content.ReadAsStringAsync(Ct);

        Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
        Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
        Assert.DoesNotContain("InvalidOperationException", body);
        Assert.DoesNotContain("dev-only-secret", body);
    }
}

Exception testi bilerek gerçekçi: kargo API'sinin sahte handler'ı, mesajında bağlantı bilgisi taşıyan bir exception fırlatıyor. Kargo client'ı bu exception tipini yakalamıyor, istek 500'e düşüyor ve testin sorusu, yanıtta exception tipi ya da mesajı var mı. Production'da yalnız ProblemDetails dönüyor.

OpenAPI testinde beklemediğim bir şey öğrendim:

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:19519/openapi/v1.json
401
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:19519/openapi/v1.json -H "Authorization: Bearer $ALAN"
404

Production'da endpoint yok, ama anonim istek 404 değil 401 aldı. Fallback policy, hiçbir endpoint'le eşleşmeyen isteklere de uygulanıyor; giriş yapmamış biri için var olan ve olmayan yollar aynı görünüyor. Bu, envanter keşfini zorlaştıran hoş bir yan etki. Testi buna göre iki satırla yazdım.

API9: OpenAPI Dokümanı Bir Envanter

OWASP'ın (Open Worldwide Application Security Project) API9 sayfası, envanter eksikliğinin iki tarafını sayıyor: hangi host'ların ve sürümlerin yayında olduğunu bilmemek ve hangi verinin hangi üçüncü tarafa aktığını bilmemek. Birincisi için elimizde hazır bir araç var: OpenAPI dokümanı. Ama yalnız runtime'da üretilen ve Development'ta açık olan bir doküman envanter sayılmaz. Envanter, kodla birlikte versiyonlanan ve değiştiğinde görünen bir dosya olmalı. Microsoft.Extensions.ApiDescription.Server paketi dokümanı build sırasında üretip bir klasöre yazabiliyor:

<!-- Build sırasında OpenAPI dokümanı üret ve repository'deki envanter klasörüne yaz -->
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../../inventory</OpenApiDocumentsDirectory>

İlk build'den sonra iki şey oldu: inventory/Bookstore.Orders.Api.json oluştu ve API projesinin klasöründe bir orders.db belirdi. Build-time üretim, dokümanı almak için uygulamanın Program.cs'ini gerçekten çalıştırıyor; bizim başlangıçtaki EnsureCreated() ve seed kodumuz da çalışmış. Microsoft dokümantasyonu bu durum için entry assembly adına bakmayı öneriyor:

// Build sırasında OpenAPI dokümanı üretilirken uygulama başlatılır: veritabanına dokunma
bool generatingDocument = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name == "GetDocument.Insider";
if (!generatingDocument)
{
    using IServiceScope scope = app.Services.CreateScope();
    OrdersDbContext db = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();
    db.Database.EnsureCreated();
    SeedData.Seed(db);
    SeedData.SeedBulkOrders(db, app.Configuration.GetValue("Lab:BulkOrders", 0));
}

Bu, gerçek bir projede daha da önemli: başlangıçta migration çalıştıran, bir message broker'a bağlanan ya da Key Vault'tan secret okuyan bir uygulama, her build'de bunları yapmaya çalışır. ASP.NET Core 11 bu alanda OpenApiGenerationEnvironment MSBuild property'sini getiriyor.

Üretilen dosya repository'ye commit ediliyor. Artık her pull request'te envanterin değişip değişmediği görünüyor. Deneyelim: biri "rapor için lazım" diyerek bütün siparişleri adresleriyle döndüren bir GET /orders/export ekliyor ve build alıyor:

$ dotnet build src/Bookstore.Orders.Api && git diff --stat -- inventory/
 inventory/Bookstore.Orders.Api.json | 50 +++++++++++++++++++++++++++++++++++++
 1 file changed, 50 insertions(+)
$ git diff -U0 -- inventory/ | head -12
diff --git a/inventory/Bookstore.Orders.Api.json b/inventory/Bookstore.Orders.Api.json
index 01d01e6..c02c7d2 100644
--- a/inventory/Bookstore.Orders.Api.json
+++ b/inventory/Bookstore.Orders.Api.json
@@ -171,0 +172,22 @@
+    "/orders/export": {
+      "get": {
+        "tags": [
+          "Bookstore.Orders.Api"
+        ],
+        "responses": {
+          "200": {

CI'da build'den sonra git diff --exit-code -- inventory/ çalıştırmak, envanteri güncellemeden yeni endpoint eklemeyi imkânsız kılıyor: dosya değiştiyse build kırılır, geliştirici güncel dosyayı commit eder ve değişiklik review'da görünür. Altıncı yazıdaki envanter testi zaten yeni endpoint için matris satırı istiyordu; bu adım da endpoint'in dış sözleşmeye eklendiğini ayrıca görünür kılıyor. İki sürüm etiketi arasındaki dosya farkı da "bu sürümde hangi endpoint'ler eklendi ya da kalktı" sorusunun cevabı.

Dosyaya bakarken bir şey daha gördüm: admin endpoint'leri de dokümanda. Envanter olarak doğru, ama bu dosyayı bir geliştirici portalında yayımlarsan admin yüzeyinin haritasını da yayımlamış olursun. Public ve admin endpoint'leri için ayrı doküman üretmek, yayımlanan dokümana ise yalnız public olanı koymak gerekiyor.

Eski Sürümleri Emekliye Ayırmak: Deprecation ve Sunset

API9'un en sık görülen hâli, kapatılması unutulan eski bir sürüm. Eski sürümler genelde yeni güvenlik kontrollerini almaz: rate limit eklenirken, BOPLA (Broken Object Property Level Authorization) düzeltilirken ya da yeni bir yetki kuralı yazılırken v1 atlanır. Sürümü kapatmak ise istemcilere zaman tanımayı gerektiriyor. İki standart header bunun için var: RFC (Request for Comments) 9745 Deprecation header'ı (Mart 2025) bir kaynağın ne zamandan beri deprecated olduğunu, RFC 8594 Sunset header'ı ise ne zaman yanıt vermeyi bırakacağını söylüyor. RFC 9745'e göre Sunset tarihi Deprecation tarihinden önce olamaz.

Asp.Versioning'in 10 serisi RFC 9745 desteğini getirdi. Lab'a iki sürümlü küçük bir katalog endpoint'i ekledim ve v1 için iki policy tanımladım:

services.AddApiVersioning(options =>
{
    options.ReportApiVersions = true;
    // v1 1 Kasım 2026'dan beri deprecated, 30 Haziran 2027'de kapanıyor; tarihler açıkça UTC
    options.Policies.Deprecate(1.0)
        .Effective(new DateTimeOffset(2026, 11, 1, 0, 0, 0, TimeSpan.Zero))
        .Link("https://docs.bookstore.test/catalog/v1-deprecation").Type("text/html");
    options.Policies.Sunset(1.0)
        .Effective(new DateTimeOffset(2027, 6, 30, 0, 0, 0, TimeSpan.Zero))
        .Link("https://docs.bookstore.test/catalog/v1-sunset").Type("text/html");
});

Yorumdaki "açıkça UTC (Coordinated Universal Time)" bir dersin sonucu. İlk sürümde .Effective(2026, 11, 1) overload'unu kullanmıştım. Lab makinesinin saat dilimi UTC+3 ve yanıt şuydu:

Sunset: Tue, 29 Jun 2027 21:00:00 GMT
Deprecation: @1793480400

@1793480400, 31 Ekim 2026 21:00 UTC. Yıl, ay, gün alan overload tarihi sunucunun yerel saat dilimine göre yorumluyor; aynı kod farklı bölgelerdeki sunucularda farklı tarihler duyurur. DateTimeOffset ile UTC'yi açıkça verdikten sonra:

$ curl -s -D - -o /dev/null http://localhost:19519/v1/catalog/books -H "Authorization: Bearer $ALAN"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sun, 08 Nov 2026 06:28:08 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
api-supported-versions: 2.0
api-deprecated-versions: 1.0
Sunset: Wed, 30 Jun 2027 00:00:00 GMT
Link: <https://docs.bookstore.test/catalog/v1-sunset>; rel="sunset"; type="text/html"
Link: <https://docs.bookstore.test/catalog/v1-deprecation>; rel="deprecation"; type="text/html"
Deprecation: @1793491200
X-Content-Type-Options: nosniff

Deprecation değeri RFC 9745'teki structured field tarih biçiminde: @ ve Unix zamanı. Sunset ise RFC 8594'ün HTTP tarih biçiminde; RFC 9745 bu farkın tarihsel sebeplerden kaldığını not ediyor. v2 isteğinde bu header'lar yok, yalnız desteklenen ve deprecated sürümlerin listesi var. Header'lar istemciyi uyarıyor ama tek başına kimseyi taşımıyor: bu header'ları alan istemcileri log'dan çıkarıp sahiplerine ulaşmak işin asıl kısmı. Sürümlemenin kendisini, yani URL (Uniform Resource Locator), query string ve header seçeneklerini ASP.NET Core Web API serisinin versioning yazısında anlatmıştık.

Envanter dosyasında bu iki sürüm tek bir /v{version}/catalog/books path'i altında görünüyor ve deprecated bilgisi yok. Sürüm başına doküman üretmek için Asp.Versioning'in API explorer entegrasyonu gerekiyor. ASP.NET Core 11 ile [Obsolete] işaretli endpoint'ler OpenAPI dokümanına otomatik olarak deprecated: true olarak yansıyacak.

Unutulan Ortamlar ve Shadow API'ler

Envanterin kodda görünmeyen kısmı ortamlar. Lab'da gördüğümüz Development ile Production farkı (OpenAPI endpoint'i yalnız birinde) küçük bir örnek; gerçek hayatta fark çok daha büyük olabiliyor. En sık karşılaştığım üç durum şunlar. Production verisinin bir kopyasıyla çalışan ve production kadar korunmayan bir staging ortamı. Bir kampanya için açılıp kampanya bitince kapatılmayan bir alt alan adı. Ve "internal" diye adlandırılmış, servisler arası kullanılsın diye yazılmış, ama yanlış bir load balancer kuralıyla internete açılmış bir endpoint.

Bunların ortak çözümü, envanteri koddan değil dışarıdan da kontrol etmek: DNS (Domain Name System) kayıtlarını ve sertifika şeffaflık log'larını düzenli taramak, gateway'deki route listesini envanter dosyasıyla karşılaştırmak, staging'de gerçek kişisel veri yerine anonimleştirilmiş veri kullanmak. Altıncı yazıdaki admin port ayrımı da bir envanter kararı: admin yüzeyi public load balancer'ın listesinde hiç yer almıyor.

Üretimde Dikkat

Yapılandırma testlerini deploy edilen artifact'a karşı da koştur. WebApplicationFactory testleri kodun Production'da ne yapacağını söyler; ortamın gerçekte nasıl yapılandırıldığını ise söylemez. Deploy'dan sonra staging'e karşı çalışan birkaç curl kontrolü (OpenAPI 401 ya da 404 mü, hata yanıtında stack trace var mı, CORS izinsiz origin'e header dönüyor mu) aynı listenin dışarıdan doğrulaması olur.

appsettings.Development.json'daki gevşek ayarların production'a sızmasına dikkat. ASPNETCORE_ENVIRONMENT değişkeninin yanlış ayarlandığı bir container, Development ayarlarıyla açılır; bunu yakalamanın en ucuz yolu uygulamanın açılışta ortamını ve kritik ayarlarını tek bir log satırıyla yazması ve monitoring'in bu satırı kontrol etmesi.

Sunset tarihi geldiğinde endpoint'i silmek yerine önce 410 Gone ya da açıklayıcı bir ProblemDetails dönen bir sürüm yayımla ve bir süre çağıranları izle. Hiç beklenmeyen bir istemci o zaman ortaya çıkar.

Sık Düşülen Tuzaklar

Birinci tuzak, yapılandırmayı yalnız Development'ta test etmek. Developer exception page, açık OpenAPI ve gevşek CORS orada normal; testin değeri Production davranışını kanıtlamasında.

İkincisi, OpenAPI dokümanını yalnız runtime'da üretmek ve envanter saymak. Commit edilmeyen bir doküman değiştiğinde kimse görmez.

Üçüncüsü, eski sürümü "kimse kullanmıyor" varsayımıyla açık bırakmak ya da tam tersine habersiz kapatmak. Deprecation ve Sunset header'ları, log'daki kullanım verisi ve istemcilere ulaşan bir duyuru birlikte çalışmalı.

Dördüncüsü, middleware sırasını çerçevenin varsayılanına bırakıp CORS, authentication ve exception handler'ın hangi sırada çalıştığını bilmemek. Sıra değişince preflight 401 alır ya da exception handler hiçbir şey yakalamaz.

Küçük Bir Deneme

★☆☆ Lab'ı Production ortamında başlat ve tablodaki her kontrolü curl ile tek tek doğrula. Hangisi test olmasaydı fark etmeyeceğin bir şeydi?

★★☆ AddOpenApi("public") ve AddOpenApi("admin") ile iki doküman üret; admin dokümanına yalnız /admin ile başlayan endpoint'leri al. Build-time üretim iki dosya yazdı mı?

★★★ CI için küçük bir script yaz: iki git etiketindeki envanter dosyalarını karşılaştırsın ve eklenen, kaldırılan ve yetki gereksinimi değişen endpoint'leri listelesin. Çıktıyı sürüm notlarına ekle.

Bir sonraki yazıda serinin kontrollerini bir araya getiriyoruz: authorization regresyonu için test piramidi, OpenAPI tabanlı tarama araçlarını yalnız kendi ortamımıza karşı çalıştırmak, 401, 403 ve 404 oranlarını tenant bazında izleyen abuse telemetry ve tek sayfalık bir API güvenlik kontrol listesi.