OWASP A10: Mishandling of Exceptional Conditions — Fail Closed ve ProblemDetails
İzin dosyası okunamayınca herkesi admin yapan bir yetki kontrolü, istemciye SQL hatasını ve dosya yolunu gönderen bir yorum endpoint'i ve istemci gittikten sonra da çalışmaya devam eden bir aramayla başlıyoruz. Fail closed, hatayı yerinde yakalamak, IExceptionHandler ve ProblemDetails, .NET 10'un handler loglama değişikliği, request timeout ve iptal ile düzeltiyoruz.
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'de admin raporlarını kimin görebileceği küçük bir JSON dosyasında duruyor. Müşteri hesabıyla bu endpoint'e istek atan Grace 403 alıyor. Bir sabah dosya bir deploy hatasıyla siliniyor. Grace aynı isteği tekrar attığında ne döner? İkinci soru: Grace aynı kitaba ikinci kez yorum yazmaya çalışıyor ve veritabanındaki unique index bunu reddediyor. Cevapta neleri görür?
İki sorunun da cevabı, kodun "her şey yolunda" durumunda nasıl davrandığına değil, işler ters gittiğinde ne yaptığına bağlı. Bu yazının konusu tam olarak o an.
A10:2025 Mishandling of Exceptional Conditions
A10:2025 Mishandling of Exceptional Conditions (istisnai durumların yanlış yönetimi) 2025 listesinin yeni kategorisi; altında 24 CWE var. İstisnai durum derken yalnızca exception'ı kastetmiyor: eksik ya da beklenmedik girdi, cevap vermeyen bir bağımlılık, dolan bir disk, tükenen bir bağlantı havuzu da bu kapsamda. Öne çıkan CWE'ler hassas bilgi içeren hata mesajı (CWE-209), fail open, yani hata anında açık kalmak (CWE-636) ve NULL dereference (CWE-476).
OWASP'ın önerileri kısa: hatayı oluştuğu yerde yakala, global bir exception handler'ı güvenlik ağı olarak kullan, yarım kalan transaction'ları geri al, kaynak tüketimine sınır koy, hata yönetimini merkezileştir ve tekrarlayan hataları izle. Bu yazıda hepsine lab'da dokunuyoruz. Örnekler kendi makinendeki lab'da çalışıyor; bu teknikleri yalnız sahibi olduğun ya da yazılı izin aldığın sistemlerde dene.
Fail Open: Hata Anında Kapıyı Açık Bırakmak
Lab'da kimlik doğrulama bu yazının konusu olmadığı için oturum açmış kullanıcı sabit: Grace, bir müşteri. Admin listesi permissions.json dosyasından okunuyor. İlk sürüm kullanıcıyı mağdur etmemek için iyi niyetli bir karar vermiş:
app.MapGet("/admin/reports", (PermissionStore permissions) =>
{
bool allowed;
try
{
allowed = permissions.LoadAdmins().Contains(Lab.CurrentUser);
}
catch (Exception)
{
// "İzin dosyasında sorun varsa kullanıcıyı dışarıda bırakma"
allowed = true;
}
return allowed ? Results.Ok(new { revenue = 1250.40m }) : Results.StatusCode(403);
});
Dosya yerindeyken her şey doğru görünüyor. Dosyayı kaldırıp aynı isteği tekrar atıyorum:
$ curl -i http://localhost:5157/admin/reports
HTTP/1.1 403 Forbidden
...
$ mv permissions.json permissions.json.bak
$ curl -i http://localhost:5157/admin/reports
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...
{"revenue":1250.40}
Yetki kontrolü bozulduğu anda herkes admin oldu. Bu, fail open. catch (Exception) burada iki hata birden yapıyor: hangi hatanın geldiğine bakmadan her şeyi yakalıyor ve güvenlik kararını en gevşek değere çekiyor. Üstelik hiçbir şey loglanmadığı için kimse bunun farkına varmıyor. Bir saldırganın yetki kontrolünü bozmasına gerek yok; bozulmasını beklemesi ya da bozacak bir girdi bulması yeter.
Exception ile Bilgi Sızdırmak
Aynı kodun yorum endpoint'i hatayı yakalayıp istemciye "yardımcı olmak" için olduğu gibi gönderiyor:
app.MapPost("/reviews", async (ReviewRequest req, AppDb db) =>
{
try
{
db.Reviews.Add(new Review { BookId = req.BookId, UserEmail = Lab.CurrentUser, Text = req.Text });
await db.SaveChangesAsync();
return Results.Ok(new { saved = true });
}
catch (Exception ex)
{
return Results.Problem(ex.ToString());
}
});
İkinci yorumu gönderince Production ortamında gelen cevap şu (3077 karakterlik detail'i kısalttım):
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"detail":"Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.\n ---> Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 19: 'UNIQUE constraint failed: Reviews.BookId, Reviews.UserEmail'.\n at Microsoft.Data.Sqlite.SqliteException.ThrowExceptionForRC(Int32 rc, sqlite3 db)\n ... at Program.<>c.<<<Main>$>b__0_3>d.MoveNext() in .../p16/Variants/Vulnerable.cs:line 31 ..."}
Bu tek cevap, veritabanının SQLite olduğunu, EF Core kullanıldığını, tablo ve kolon adlarını, unique index'in hangi kolonlarda olduğunu ve sunucudaki kaynak dosyasının tam yolunu söylüyor. Production'da developer exception page kapalı olsa bile, hatayı elle cevaba koyan kod aynı sızıntıyı yaratıyor. Bu CWE-209'un ders kitabı örneği. Saldırgan için her biri bir sonraki adımı daraltan bir ipucu.
Fail Closed ve Hatayı Yerinde Yakalamak
Düzeltilmiş sürümde iki ilke var: hatayı oluştuğu yerde ve yalnızca beklediğin türüyle yakala; güvenlik kararını veremiyorsan reddet.
app.MapGet("/admin/reports", (PermissionStore permissions) =>
{
HashSet<string> admins;
try
{
admins = permissions.LoadAdmins();
}
catch (Exception ex) when (ex is IOException or JsonException)
{
// Karar verilemiyorsa erişim yok: hatayı anlamlı bir tipe çevirip yukarı fırlat
throw new PermissionStoreUnavailableException("Permission store could not be read.", ex);
}
return admins.Contains(Lab.CurrentUser) ? Results.Ok(new { revenue = 1250.40m }) : Results.StatusCode(403);
});
app.MapPost("/reviews", async (ReviewRequest req, AppDb db) =>
{
db.Reviews.Add(new Review { BookId = req.BookId, UserEmail = Lab.CurrentUser, Text = req.Text });
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (ex.InnerException is SqliteException { SqliteExtendedErrorCode: 2067 })
{
// 2067 = SQLITE_CONSTRAINT_UNIQUE; diğer kısıt hataları burada yakalanmaz
return Results.Problem("You have already reviewed this book.", statusCode: StatusCodes.Status409Conflict);
}
return Results.Ok(new { saved = true });
});
Yorum endpoint'i yalnızca unique kısıtını, yani gerçekten beklediği durumu yakalıyor ve kullanıcıya anlamlı bir 409 dönüyor. Genel SQLite hata kodu 19 tüm kısıt hatalarını kapsadığı için extended kod olan 2067'ye bakmak önemli; birazdan NOT NULL hatasının neden buraya düşmemesi gerektiğini göreceğiz. Yetki endpoint'i ise dosya okunamadığında kendisi karar vermiyor, hatayı anlamlı bir tipe çevirip yukarı fırlatıyor:
$ mv permissions.json permissions.json.bak
$ curl -i http://localhost:5157/admin/reports
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"Service temporarily unavailable.","status":503,"traceId":"00-8a2017000c4889e1d6bff8ae602b30aa-3363a83f256cfbe5-00"}
$ curl -i -X POST http://localhost:5157/reviews -H "Content-Type: application/json" -d '{"bookId":7,"text":"Great again"}'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict","status":409,"detail":"You have already reviewed this book.","traceId":"00-8022f69d48a4b7a3cf33361496b3f5c4-917481748321b80c-00"}
Dosya yokken Grace artık rapor görmüyor; 503 dürüst bir cevap, çünkü sorun Grace'te değil sistemde. Hiçbir cevapta iç ayrıntı yok, yalnızca destek ekibinin loglarda arayabileceği bir traceId var.
Güvenlik Ağı: UseExceptionHandler, IExceptionHandler ve ProblemDetails
503'ü üreten parça merkezi exception handler. Kurulum üç satır, eşleme de küçük bir class:
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<PermissionStoreExceptionHandler>();
...
app.UseExceptionHandler();
public sealed class PermissionStoreExceptionHandler(IProblemDetailsService problemDetails,
ILogger<PermissionStoreExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception exception, CancellationToken ct)
{
if (exception is not PermissionStoreUnavailableException) return false;
// .NET 10: handler'ın işlediği exception'ı middleware loglamaz, logu biz yazarız
logger.LogError(exception, "Permission store unavailable, denying {Path}", http.Request.Path);
http.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = http,
Exception = exception,
ProblemDetails = { Title = "Service temporarily unavailable.", Status = StatusCodes.Status503ServiceUnavailable }
});
}
}
TryHandleAsync tanımadığı exception için false dönüyor ve iş varsayılan davranışa kalıyor: ProblemDetails formatında, ayrıntısız bir 500. Bunu görmek için gövdeye text koymadan bir yorum gönderiyorum; ne DTO ne de endpoint bu durumu bekliyor:
$ curl -i -X POST http://localhost:5157/reviews -H "Content-Type: application/json" -d '{"bookId":8}'
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-1426148f46095589fad11a21bf18a5f1-ee233327324c94f9-00"}
Ayrıntılar istemcide değil logda:
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
An unhandled exception has occurred while executing the request.
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 19: 'NOT NULL constraint failed: Reviews.Text'.
Hata kodu yine 19, ama extended kod 2067 değil; 409 dalına düşseydi kullanıcıya "bu kitaba zaten yorum yazdın" diye yalan söylerdik. Asıl düzeltme tabii bu isteği girişte doğrulamak; global handler, unuttuğun durumlar için bir ağ.
.NET 10'daki değişikliği de burada gördüm. Handler'daki LogError satırını yorum satırı yapıp 503 isteğini tekrarladığımda sunucu logunda tek bir kayıt bile yoktu. Middleware, bir IExceptionHandler'ın işlediği exception'lar için artık kendi kaydını yazmıyor; bu davranış ExceptionHandlerOptions.SuppressDiagnosticsCallback ile değiştirilebiliyor. Yani her handler kendi logunu yazmalı, yoksa A09 yazısında kurduğun alarmlar o hataları hiç görmez.
Timeout, İptal ve Kaynak Tüketimi
Zafiyetli sürümde arama endpoint'i yavaş bir bağımlılığı 10 saniye bekliyor ve CancellationToken almıyor. İstemci 3 saniyede vazgeçiyor:
$ date -u +%T; curl -s -m 3 "http://localhost:5157/search?q=tolkien"; echo "curl exit $?"; date -u +%T
19:36:56
curl exit 28
19:36:59
curl'ün 28 numaralı çıkış kodu timeout demek. Sunucu logundaki satır ise 19:37:06'da, isteğin başlamasından 10 saniye sonra düştü:
info: Program[0]
Search for tolkien finished
İstemci gitti ama sunucu işi sonuna kadar sürdürdü. Bu tek istekte zararsız; aynı endpoint'e yüzlerce istek gelince thread'ler, bağlantılar ve bağımlılığın kapasitesi cevabını kimsenin beklemediği işlere harcanır. Bu, bir kaynak tüketimi saldırısının en ucuz hâli. Düzeltmede iptali zincir boyunca taşıyor ve endpoint'e bir süre sınırı koyuyorum:
builder.Services.AddRequestTimeouts();
...
app.UseRequestTimeouts();
app.MapGet("/search", async (string q, ILogger<Program> logger, CancellationToken ct) =>
{
await Task.Delay(TimeSpan.FromSeconds(10), ct);
logger.LogInformation("Search for {Query} finished", q);
return Results.Ok(Array.Empty<string>());
}).WithRequestTimeout(TimeSpan.FromSeconds(2));
$ date -u +%T; curl -s -i -m 5 "http://localhost:5157/search?q=tolkien"; echo "curl exit $?"; date -u +%T
19:36:04
HTTP/1.1 504 Gateway Timeout
Content-Length: 0
...
curl exit 0
19:36:06
İki saniyede 504 döndü, logda Timeout exception handled. uyarısı var ve "finished" satırı hiç yazılmadı; iş gerçekten durdu. Timeout, endpoint'in aldığı CancellationToken üzerinden çalışıyor; token'ı veritabanı ve HttpClient çağrılarına geçirmezsen middleware cevap verir ama arkadaki iş sürer.
Kaynak tüketiminin diğer sınırları da varsayılanlarını bilmeyi gerektiriyor. Kestrel'in MaxRequestBodySize değeri lab'da yazdırınca 30000000 byte çıktı, yani yaklaşık 28,6 MB; küçük JSON alan bir API için fazla, endpoint bazında RequestSizeLimit metadata'sıyla düşür. İstek sayısına sınır için A06 yazısındaki rate limiting'i kullan.
Sık Düşülen Tuzaklar
İlk tuzak, yutulmuş hata: catch { } ya da catch (Exception) { return null; }. Hata kaybolur, çağıran taraf null'ı "kayıt yok" diye yorumlar ve sorun başka bir yerde, başka bir kılıkta ortaya çıkar. Yakalıyorsan ya düzelt, ya logla ve yeniden fırlat, ya da anlamlı bir hataya çevir.
İkincisi, yarım kalan işler. Bir siparişte stok düşüp ödeme kaydı yazılmadan hata olursa veri tutarsız kalır. EF Core'da tek bir SaveChangesAsync zaten atomiktir; birden fazla adım varsa BeginTransactionAsync ile açtığın transaction'ı await using ile tut, CommitAsync çağrılmadan dispose edilirse geri alınır.
Üçüncüsü, timeout'u debugger altında test etmek. Request timeouts middleware'i debugger bağlıyken devreye girmez; Visual Studio'dan F5 ile denediğinde timeout olmadığını görüp ayarın çalışmadığını sanabilirsin. Lab'ı dotnet run ya da build çıktısıyla çalıştır.
Dördüncüsü, catch (Exception)'ı her endpoint'e kopyalamak. Global handler varken endpoint'te yalnızca beklediğin, anlamlı bir cevaba çevirebileceğin exception'ları yakala; gerisini handler'a bırak.
Kontrol Listesi
Güvenlik kararı veren her kod yolu, karar veremediği durumda reddediyor mu? catch blokları yalnızca beklenen exception tiplerini yakalıyor mu, ve hiçbiri sessizce yutmuyor mu? İstemciye giden hiçbir cevapta stack trace, SQL, tablo adı ya da dosya yolu yok mu?
AddProblemDetails, UseExceptionHandler ve gerekiyorsa IExceptionHandler kurulu mu, ve her handler kendi logunu yazıyor mu? Uzun sürebilecek endpoint'lerde timeout var mı ve CancellationToken bütün çağrılara geçiyor mu? İstek gövdesi boyutu ve istek sayısı sınırlı mı? Çok adımlı işler transaction içinde mi?
Küçük Bir Deneme
★☆☆ permissions.json dosyasını silmek yerine içine geçersiz JSON yaz. Zafiyetli ve düzeltilmiş sürümün nasıl davrandığını karşılaştır ve JsonException'ın hangi satırda yakalandığını bul.
★★☆ Arama endpoint'ine HttpClient ile yavaş bir yerel endpoint'i çağıran bir sürüm ekle ve token'ı geçirmediğinde timeout'tan sonra isteğin arka planda sürdüğünü logla göster. Sonra token'ı geçir ve farkı gözle.
★★★ Handler'daki LogError satırını kaldırıp ExceptionHandlerOptions.SuppressDiagnosticsCallback ile middleware'in handler'ın işlediği exception'ları da loglamasını sağla. Hangi yaklaşımın log kaydını daha kullanışlı yaptığını karşılaştır.
A10 ile 2025 listesinin on kategorisini de tamamladık. Bir sonraki yazıda hepsini birleştiriyoruz: ASP.NET Core ve Angular için güvenli varsayılanlar, güvenlik testleri ve Top 10'dan OWASP ASVS'ye geçiş.