İçeriğe geç
Hüseyin Bozdemir
Tüm yazılar

IDOR: ASP.NET Core'da [Authorize] neden yetmez?

Ödeme sistemleri rehberinin 5. bölümü

Giriş yapmış bir kullanıcı başkasının kaydını açabiliyorsa sorun kimlikte değil, aitliktedir. ASP.NET Core'da aitlik kontrolünü unutulamaz hale getirmek.

14 dk okumaGüncellendi:

Kod fontuyla [Authorize] ve altında 'yetmez.' başlığı; sağda bir isteğin akışı: token geçerli, işlem yetkisi kontrolünden geçmiş, kaynak aitliği kontrolü kesikli çizgiyle boş bırakılmış, sonuçta 200 OK ile başkasının kaydı dönmüş.
İçindekiler
  1. Kimlik doğrulama “kimsin” der, “bu senin mi” demez
  2. Aitlik tek seviyeli değildir
  3. Her controller’a elle yazılan kontrol
  4. Önerdiğim yapı: kaynak kendi sahibini söylesin
  5. Marker interface
  6. Tek sorguda “var mı ve senin mi”
  7. ASP.NET Core’un resource-based authorization’ı neden yetmez
  8. Global query filter neden tek başına yetmez
  9. Body’deki sahip id’sine güvenme
  10. Mikroservis ve kuyruk tarafı
  11. GUID seni korumaz
  12. 403 mü, 404 mü?
  13. Kendi API’ni nasıl test edersin
  14. Okura kalan
  15. Sık sorulanlar
  16. IDOR ile BOLA aynı şey mi?
  17. GUID kullanmak IDOR’u önler mi?
  18. Başkasının kaydına 403 mü 404 mü dönmeli?

IDOR (Insecure Direct Object Reference), giriş yapmış bir kullanıcının URL’deki ya da istekteki id’yi değiştirerek kendisine ait olmayan bir kayda erişebilmesidir. Kendi yaptığım bir güvenlik testinde tam bunu gördüm: aitlik kontrolü her controller’da elle yazılmıştı ve bir endpoint’te unutulmuştu. Kimlik doğrulama kusursuz çalışıyordu. Kullanıcı giriş yapmıştı, token geçerliydi, [Authorize] yerindeydi. Ama o endpoint, istenen kaydın o kullanıcıya ait olup olmadığına hiç bakmıyordu.

IDOR, OWASP API güvenliği listesinde BOLA (Broken Object Level Authorization) adıyla birinci sırada duruyor. GET /cards/{id} isteğinde kendi kartının id’sini başka bir kartınkiyle değiştirmek yetiyor. Yani eksik olan kimlik doğrulama değil, kaynak sahipliği kontrolü; bu yazıda ona kısaca aitlik diyeceğim.

Bu yazı “id’yi kontrol et” demek için değil. Herkes id’yi kontrol etmesi gerektiğini biliyor. Asıl soru şu: bu kontrolü, yarın yeni bir endpoint yazan birinin unutamayacağı bir yere nasıl koyarsın?

Kimlik doğrulama “kimsin” der, “bu senin mi” demez

Kurumsal kart yöneten bir API’den tipik bir endpoint:

[Authorize]
[HttpGet("cards/{id:guid}")]
public async Task<IActionResult> GetCard(Guid id, CancellationToken ct)
{
    var card = await db.Cards.FindAsync([id], ct);
    return card is null ? NotFound() : Ok(CardDto.From(card));
}

[Authorize] burada tek bir soruya cevap veriyor: bu isteği atan, sistemin tanıdığı biri mi? Cevap evetse kapı açılıyor. Ama “bu kart bu kişiye mi ait?” sorusunu kimse sormuyor. Token’ı geçerli olan herkes, id’sini bildiği her kartı okuyabiliyor.

Rol ve yetki kontrolü de bu boşluğu doldurmuyor. Bizde her endpoint bir izne (permission) bağlı ve kullanıcı tipi ne olursa olsun, admin bile olsa, o izne sahip değilse endpoint’e erişemiyor. Bu iyi bir kural, ama başka bir soruya cevap veriyor: “Bu işlemi yapabilir misin?” Kart görüntüleme iznine sahip bir kullanıcı, bu izin sayesinde başkasının kartını da görebiliyorsa sorun yine orada duruyor.

Yani her istekte iki ayrı kontrol var ve ikisinin de geçmesi gerekiyor:

  1. İşlem yetkisi: Bu kullanıcı bu işlemi yapabilir mi? (rol, permission, policy)
  2. Kaynak aitliği: Bu kullanıcı bu kayıt üzerinde bu işlemi yapabilir mi?
Her istekte iki ayrı kontrolToken'lı istek önce işlem yetkisi kontrolünden geçer: kullanıcı bu işlemi yapabilir mi, endpoint'in bağlı olduğu izne bakılır. Geçemezse 403 döner. Sonra kaynak aitliği kontrolünden geçer: kayıt kullanıcıya ait mi, kaydın departman id'si token'daki listede mi diye bakılır. Geçemezse 403 ya da 404 döner. İkisi de geçerse kayıt döner.İstek + token1. İşlem yetkisiBu işlemi yapabilir mi?permissionhayır2. Kaynak aitliğiBu kayıt onun mu?DepartmentId ∈ tokenhayır403403 / 404evetevetKayıt döner
[Authorize] ve rol kontrolü ilk kapıyı açar. İkinci kapının cevabını yalnızca senin domain'in bilir.

Framework’ler birincisi için hazır araç verir. İkincisi senin domain’ine ait, onu kimse senin yerine bilemez.

Aitlik tek seviyeli değildir

“Kayıt kullanıcıya ait mi” diye düşününce iş kolay görünüyor: card.UserId == currentUserId. Gerçek sistemlerde aitlik nadiren bu kadar düz.

Şöyle bir örnek düşün: şirketlere kurumsal kart veren bir sistem. Bir şirketin üç departmanı var ve her departmanın çalışanlarına verilmiş kartlar var. Her kart ayrı bir kaynak. Kurallar şöyle:

  • Şirket yöneticisi tüm departmanların kartlarını görebilir.
  • Departman yöneticisi yalnızca kendi departmanının kartlarını görebilir; aynı şirketteki başka bir departmanın kartını göremez.

Kart doğrudan kullanıcıya bağlı değil. Kart departmana ait, departman şirkete. Kullanıcı ise ya şirket adına ya da belirli bir departman adına işlem yapıyor. “Bu kart senin mi” sorusunun cevabı, kullanıcının hiyerarşide nerede durduğuna bağlı.

Bunu çözmek için token’da kullanıcının ana kaynaklarını taşımak işe yarıyor; bizde de yaklaşım bu. Örnekte bu, şirket id’si ve erişilebilen departman id’leri demek. Şirket yöneticisinin token’ında üç departmanın id’si birden bulunuyor, departman yöneticisinin token’ında yalnızca kendi departmanı. Böylece aitlik sorusu tek bir kurala iniyor: kaydın DepartmentId’si, token’daki departman listesinde var mı?

Şirket, departman ve kart aitlik hiyerarşisiBir şirkete bağlı Departman A, Departman B ve Departman C var; her departmanın kendi kartları bulunuyor. Şirket yöneticisinin token'ında üç departmanın id'si de var ve tüm kartları görüyor. Departman B yöneticisinin token'ında yalnızca Departman B var ve yalnızca Departman B'nin kartlarını görüyor.ŞirketDepartman AKartKartDepartman BKartKartDepartman CKartKart
Kart doğrudan kullanıcıya değil departmana, departman da şirkete ait. Kimin neyi göreceğini token'daki departman listesi belirliyor.
public interface ICurrentUser
{
    Guid UserId { get; }
    Guid CompanyId { get; }
    IReadOnlyCollection<Guid> DepartmentIds { get; }
}

Bunun bir bedeli var ve bilerek ödemen gerekiyor: token’daki liste, token’ın ömrü boyunca sabit kalıyor. Bir kullanıcının bir departmana erişimini kaldırdığında bu değişiklik, kullanıcının elindeki token süresi dolana kadar yansımıyor. Token ömrünü buna göre kısa tutmak ya da kritik işlemlerde listeyi kaynaktan tekrar doğrulamak gerekiyor.

Her controller’a elle yazılan kontrol

Başlangıçta aitlik kontrolü her action’ın içinde elle yazılıydı:

var card = await db.Cards.FindAsync([id], ct);
if (card is null)
    return NotFound();

if (!currentUser.DepartmentIds.Contains(card.DepartmentId))
    return Forbid();

Tek başına bakınca bu kodda yanlış bir şey yok. Sorun, doğruluğunun tamamen hafızaya bağlı olması. Yüz endpoint varsa yüz kez hatırlaman gerekiyor. Yeni gelen biri, acil bir düzeltme, kopyalanıp kısaltılmış bir action: biri bir gün mutlaka atlıyor. Benim testte bulduğum da tam olarak buydu. Ortada karmaşık bir açık yoktu, sadece yazılmamış dört satır vardı.

İlk adım olarak bu kontrolü merkezi bir metoda topladık:

var card = await db.Cards.FindAsync([id], ct);
if (card is null)
    return NotFound();

accessValidator.ValidateDepartmentAccess(card.DepartmentId); // erişim yoksa exception

Tekrar azaldı, kural tek bir yerde toplandı. Ama asıl sorun yerinde duruyor: ValidateDepartmentAccess’i çağırmak yine geliştiricinin aklına kalmış. Kaydı getirmek ve kontrol etmek hâlâ iki ayrı adım ve ikincisini atlasan da kod derleniyor, test geçiyor, endpoint çalışıyor.

Kontrolü daha da merkeze, örneğin bir filter’a ya da middleware’e taşımak istediğinde ise başka bir duvara çarpıyorsun: erişilmeye çalışılan kaynağın tipini nasıl bilecek? Middleware URL’de bir Guid görüyor. O id’nin bir karta mı, bir işleme mi, bir departmana mı ait olduğunu, hangi tabloya bakacağını ve hangi alanın sahibi gösterdiğini bilmiyor.

Önerdiğim yapı: kaynak kendi sahibini söylesin

Bu sorunun cevabı, bilgiyi kontrol eden tarafa değil, kaynağın kendisine koymak. Hangi alanın sahibi gösterdiğini entity’nin kendisi söylerse, generic bir kontrol her kaynak için çalışabilir.

Marker interface

public interface IEntity
{
    Guid Id { get; }
}

public interface IDepartmentScoped : IEntity
{
    Guid DepartmentId { get; }
}

public sealed class Card : IDepartmentScoped
{
    public Guid Id { get; init; }
    public Guid DepartmentId { get; init; }
    public string HolderName { get; init; } = "";
    public bool IsFrozen { get; set; }
    // ...
}

Artık “bu kaynağın sahibi kim” sorusunun cevabı tipte yazıyor. Şirket seviyesinde sahiplenilen kaynaklar için aynı şekilde bir ICompanyScoped tanımlayabilirsin. Önemli olan, bir kaynağın hangi seviyede sahiplenildiğini derleyicinin de görebilmesi.

Tek sorguda “var mı ve senin mi”

Bu interface’in üzerine generic bir koruma yazınca, kaydı getirmek ile aitliği kontrol etmek tek bir çağrıya dönüşüyor:

public sealed class OwnershipGuard(AppDbContext db, ICurrentUser currentUser)
{
    public async Task<T> GetOwnedAsync<T>(Guid id, CancellationToken ct)
        where T : class, IDepartmentScoped
    {
        var departmentIds = currentUser.DepartmentIds;

        var entity = await db.Set<T>()
            .Where(x => x.Id == id && departmentIds.Contains(x.DepartmentId))
            .SingleOrDefaultAsync(ct);

        return entity ?? throw new ResourceNotAccessibleException(typeof(T).Name, id);
    }
}

Normal akışta kullanıcı kendi kaydını istediği için bu exception seyrek fırlar; exception’ın maliyeti seyrek durumda hissedilmez.

Controller da şu hale geliyor:

[HttpGet("cards/{id:guid}")]
public async Task<CardDto> GetCard(Guid id, CancellationToken ct)
{
    var card = await guard.GetOwnedAsync<Card>(id, ct);
    return CardDto.From(card);
}

Fark küçük görünüyor ama önemli. Eskiden kontrolü unutmak için bir satırı yazmamak yeterliydi. Şimdi kontrolü unutmak için kaydı da getirmemen gerekiyor. Kaydı getirmenin yolu guard’dan geçiyorsa, aitlik kontrolü o yolun bir parçası oluyor, ayrı bir adım olmaktan çıkıyor.

Bunu tam anlamıyla işe yaratmak için bir kural daha gerekiyor: controller’lar DbContext’e doğrudan dokunmamalı. Yoksa biri yine db.Cards.FindAsync yazar ve guard’ı hiç çağırmaz. Bunu code review’a bırakmak yerine bir mimari testle de zorlayabilirsin: “API katmanındaki hiçbir tip AppDbContext’e bağımlı olamaz” gibi bir kural.

Sorgunun yapısına da dikkat et. “Var mı” ve “senin mi” tek bir WHERE ile soruluyor. Önce kaydı getirip sonra sahibine bakmıyorsun, başkasının kaydı belleğe hiç gelmiyor. Bunun 403/404 tartışmasında da işine yarayacak bir yan etkisi var, aşağıda değineceğim.

ASP.NET Core’un resource-based authorization’ı neden yetmez

ASP.NET Core’un bu iş için kendi aracı da var: resource-based authorization. Kuralı bir AuthorizationHandler içine yazıyorsun, controller’da kaydı getirdikten sonra IAuthorizationService ile kontrol ediyorsun:

var card = await db.Cards.FindAsync([id], ct);
if (card is null)
    return NotFound();

var result = await authorizationService.AuthorizeAsync(User, card, "SameDepartment");
if (!result.Succeeded)
    return Forbid();

Kuralı tek bir yerde topladığı için iyi bir araç. Ama yapısı yukarıdaki ValidateDepartmentAccess ile aynı: önce kaydı getiriyorsun, sonra kontrolü çağırıyorsun. Çağırmayı unutursan kod yine derleniyor, endpoint yine çalışıyor. Üstelik kayıt kontrolden önce belleğe geliyor; yok ile senin değil arasındaki fark, 404 ve 403 olarak istemciye de yansıyor. Handler’ı kural sınıfı olarak kullanmaya devam edebilirsin, ama kontrolün çağrılmasını geliştiricinin hafızasına bırakmamak için kaydı getiren yolun içine koyman gerekiyor.

Global query filter neden tek başına yetmez

EF Core’da aynı işi global query filter ile de yapabilirsin. IDepartmentScoped uygulayan her entity’ye DepartmentId’yi token’daki listeyle süzen bir filtre eklersin, her sorgu otomatik olarak filtrelenir. Kâğıt üzerinde daha da güvenli görünüyor çünkü çağırmayı unutabileceğin bir şey bile kalmıyor.

Pratikte bu filtreyi son savunma hattı olarak görmek daha doğru:

  • Bir yerde IgnoreQueryFilters() çağrıldığında, örneğin bir admin ekranı için, filtre o sorguda tamamen devre dışı kalıyor. Bunu kodda aramak zorundasın.
  • Dapper ile ya da stored procedure ile yazılmış raporlama sorguları filtreyi hiç görmüyor.
  • Filtre görünmez çalışıyor. Kaydın neden gelmediğini anlamaya çalışan bir geliştirici önce veriyi, sonra sorguyu kontrol ediyor, filtre en son aklına geliyor.

Filtreyi açarsan zarar etmez, bir katman daha eklemiş olursun. Ama “aitlik nerede kontrol ediliyor?” sorusunun cevabı açık ve okunabilir bir yerde olmalı. O yer de kaydı getiren çağrının kendisi.

Body’deki sahip id’sine güvenme

Aitlik sadece URL’deki id ile ilgili değil. İstek gövdesi de aynı riski taşıyor:

public sealed record IssueCardRequest(Guid CompanyId, Guid DepartmentId, string HolderName);

İstemci CompanyId olarak başka bir şirketin id’sini gönderirse ne olacak? Bizde bu soru tek bir kuralla kapanıyor: body’de sahip id’si gelse bile token’daki değer esas alınır. İstemcinin gönderdiği değer ezilir.

var command = new IssueCardCommand(
    CompanyId: currentUser.CompanyId,   // body'deki değer değil
    DepartmentId: request.DepartmentId,
    HolderName: request.HolderName);

if (!currentUser.DepartmentIds.Contains(command.DepartmentId))
    throw new ResourceNotAccessibleException(nameof(Department), command.DepartmentId);

Burada iki farklı durum var. CompanyId gibi kullanıcının tek bir değeri olan alanlar doğrudan token’dan alınır, istemcinin ne gönderdiğinin önemi yoktur. DepartmentId gibi birden fazla geçerli değeri olabilen alanlar ise ezilemez, token’daki listeye karşı doğrulanır. En temizi, ezeceğin alanı request DTO’suna hiç koymamak. Ama eski bir sözleşme yüzünden alan oradaysa, ona güvenme.

Mikroservis ve kuyruk tarafı

Mikroservislerde aitlik bilgisinin yolda kaybolması kolay. Gateway token’ı doğrular ve isteği arkaya iletir. Arkadaki servis “gateway zaten kontrol etti” diye düşünebilir. Ama gateway kimlik doğrulamasını yapabilir, aitliği yapamaz: kart kaydını görmüyor, hangi departmana ait olduğunu bilmiyor. Aitlik sorusunu ancak verinin sahibi olan servis cevaplayabilir.

Bu yüzden ana kaynakları token’da taşımak burada da işe yarıyor. Kullanıcının id’si, bağlı olduğu şirket ve departman id’leri token’da olduğu için her servis, aitlik sorusunu kendi verisine karşı, başka bir servise sormadan cevaplayabiliyor.

Asenkron akışta durum biraz daha ince. Bizde aitlik kontrolü genellikle mesaj publish edilmeden önce yapılmış oluyor. Buna rağmen consumer mesajdaki id’ye körü körüne güvenmiyor. Kullanıcı bağlamı mesajla birlikte taşınıyor ve consumer, mesaj gövdesindeki sahip id’sini bu bağlamdaki değerle ezip kaynağın gerçekten var olup olmadığına bakıyor:

public sealed record UserContext(Guid UserId, Guid CompanyId, IReadOnlyCollection<Guid> DepartmentIds);

public sealed record CardFreezeRequested(Guid CardId, UserContext RequestedBy);

public sealed class CardFreezeRequestedHandler(AppDbContext db) : IMessageHandler<CardFreezeRequested>
{
    public async Task HandleAsync(CardFreezeRequested message, CancellationToken ct)
    {
        var departmentIds = message.RequestedBy.DepartmentIds;

        var card = await db.Cards
            .Where(x => x.Id == message.CardId && departmentIds.Contains(x.DepartmentId))
            .SingleOrDefaultAsync(ct);

        if (card is null)
            return; // logla; böyle bir kaynak yok ya da bu kullanıcıya ait değil

        card.IsFrozen = true;
        await db.SaveChangesAsync(ct);
    }
}

Publisher’da kontrol edilmişken bunu consumer’da tekrar yapmak gereksiz görünebilir. Ama mesaj, publish ile consume arasında bir sınırı geçiyor. Yarın aynı kuyruğa başka bir servis de mesaj atmaya başlayabilir, ya da biri bir mesajı elle yeniden kuyruğa koyabilir. Consumer’ın “bu mesaj güvenilir bir yerden geldi” varsayımı, kontrolü atlamak için yeterli bir sebep değil. Kontrol ucuz, unutulduğunda ortaya çıkan sonuç pahalı.

GUID seni korumaz

IDOR konuşulurken mutlaka biri şunu söyler: “Bizde id’ler GUID, kimse tahmin edemez.” Bizde de id’ler GUID’di. Unutulmuş kontrol yine de oradaydı.

GUID’in tahmin edilmesi zor, bu doğru. Ama bir saldırganın id’yi tahmin etmesine gerek yok, bulması yeterli. GUID’ler sanıldığından çok daha fazla yere sızıyor:

  • URL’lerde taşındıkları için tarayıcı geçmişine, proxy ve uygulama loglarına düşüyorlar.
  • E-postayla ya da mesajla paylaşılan linklerin içinde dolaşıyorlar.
  • Destek talebine eklenen ekran görüntülerinde görünüyorlar.
  • Başka bir endpoint’in yanıtında ilişkili kaydın id’si olarak dönüyorlar. Bir departmanın görebildiği bir harcama kaydı, başka bir kaynağın id’sini taşıyabiliyor.

Daha önemlisi, id’nin tahmin edilemez olması tasarımın bilinçli bir güvenlik özelliği değil, bir yan etkisi. Yarın biri performans için sıralı bir id şemasına geçerse, aitlik kontrolünü GUID’e emanet ettiğin her yerde güvenliğin de onunla birlikte gider. GUID saldırıyı zorlaştırır, yetkilendirmenin yerini tutmaz.

403 mü, 404 mü?

Başkasına ait bir kayda erişilmeye çalışıldığında ne dönmeli? Bizde mimari tercih 403 Forbidden’dı. Ben 404 Not Found’un daha sağlıklı olduğunu düşünüyorum.

403, “bu kayıt var ama senin değil” demek. Yani kaydın varlığını doğruluyor. Elinde bir id listesi olan biri, hangilerinin gerçek olduğunu öğrenebiliyor. 404 ise iki durumu ayırt etmiyor: kayıt ya yok ya da senin değil. GitHub’ın, erişimin olmayan private repository’ler için 404 dönmesinin sebebi de bu.

Aslında asıl sızıntı 403 ya da 404 seçiminde değil, bu ikisinin farklı durumlarda dönmesinde. Var olmayan kayda 404, başkasının kaydına 403 dönen bir API, ikisinin farkını açıkça söylüyor. Yukarıdaki GetOwnedAsync tek sorguyla çalıştığı için iki durumu zaten ayırt edemiyor ve ikisine de aynı yanıtı dönüyor. Hangi kodu seçtiğin, tutarlı olmaktan daha az önemli.

404’ün de bir bedeli var. İstemci ya da destek ekibi “kayıt yok mu, yoksa yetkim mi yok?” ayrımını yanıttan yapamıyor. Bu ayrımı API yanıtında değil, sunucu tarafındaki logda tutman gerekiyor. Logda gerçek sebebi açıkça yaz, istemciye ise genel yanıtı dön.

Kendi API’ni nasıl test edersin

İki kullanıcıyla giriş yap. A’nın kaydının id’sini B’nin token’ıyla iste ve 404 bekle. Bunu elle bir kez yapmak açığı bulur; integration testlerine her endpoint için ekleyince açığın geri gelmesini de engeller.

Okura kalan

  • [Authorize] ve rol kontrolü “bu işlemi yapabilir misin” sorusunu cevaplar. “Bu kayıt senin mi” ayrı bir soru ve onu ayrıca sorman gerekir.
  • Aitlik kontrolünü her endpoint’e elle yazarsan bir gün biri unutur. Kontrolü, kaydı getiren çağrının parçası yap ki unutmak için kaydı da getirmemek gereksin.
  • Kaynağın sahibini entity’nin kendisine söylet (IDepartmentScoped). Generic bir kontrol ancak tipi tanıyabilirse çalışır.
  • Body’deki ve kuyruk mesajındaki sahip id’lerine güvenme. Tek değerli olanları token’dan al, çok değerli olanları token’a karşı doğrula.
  • GUID id’yi bulmayı zorlaştırır, yetkilendirmenin yerini tutmaz. 403 ya da 404, hangisini seçersen seç, iki durumu birbirinden ayırt ettirme.
  • Daha fazlası için: OWASP IDOR Prevention Cheat Sheet.

Sık sorulanlar

IDOR ile BOLA aynı şey mi?

Aynı açığın iki adı. IDOR (Insecure Direct Object Reference) eski ve yaygın ad; OWASP API güvenliği listesi aynı sorunu BOLA (Broken Object Level Authorization) adıyla birinci sırada tutuyor. İkisinde de eksik olan kimlik doğrulama değil, kaynak sahipliği kontrolü.

GUID kullanmak IDOR’u önler mi?

Önlemez, yalnızca saldırıyı zorlaştırır. GUID’in tahmin edilmesi zor ama bulunması kolay: URL’lerde, loglarda, paylaşılan linklerde ve başka endpoint’lerin yanıtlarında dolaşıyor. Aitlik kontrolünü GUID’e emanet edersen, biri sıralı id’ye geçtiği gün güvenlik de onunla gider.

Başkasının kaydına 403 mü 404 mü dönmeli?

Ben 404’ü daha sağlıklı buluyorum; 403 kaydın var olduğunu doğrular. Asıl sızıntı ise ikisinin farklı durumlarda dönmesi: var olmayan kayda 404, başkasının kaydına 403 dönen bir API farkı açıkça söyler. Hangi kodu seçersen seç, iki durumu birbirinden ayırt ettirme ve gerçek sebebi sunucu logunda tut.