API Yetkilendirme Hataları: IDOR'u UUID ile Çözdüğünü Sanma
API güvenliğinde en pahalı hata genelde authentication değil authorization tarafında çıkar. IDOR, tenant izolasyonu ve obje bazlı kontrol için pratik bir test planı.
API Yetkilendirme Hataları: IDOR'u UUID ile Çözdüğünü Sanma#
Bir API'de login çalışıyor diye kullanıcı doğru veriye erişiyor sanmak pahalı bir yanılgıdır. Gerçek güvenlik sorusu şudur: Bu kullanıcı bu objeyi, bu tenant içinde, bu aksiyonla gerçekten görmeli mi?
IDOR çoğu zaman id=123 değerini id=124 yapmak kadar karikatürize anlatılır. Üretimdeki açıklar daha sessizdir: tahmin edilemeyen UUID'ler, eksik tenant filtresi, admin panelinden kopyalanmış servis metodu veya frontend'de gizlenmiş ama API'de yaşayan aksiyonlar.
UUID tahmin edilebilirliği değil, yetki kararı önemlidir#
UUID kullanmak iyi bir hijyen olabilir ama yetkilendirme kontrolü değildir. Çünkü saldırganın objenin ID'sini öğrenmesi için tek yol brute force değildir.
ID şu yerlerden sızabilir:
- E-posta bildirimleri
- Export dosyaları
- Log kayıtları
- Referer header'ları
- Paylaşılmış linkler
- Mobil uygulama cache'i
- GraphQL response içinde gömülü ilişkiler
ID bilindiği anda sistemin karar vermesi gerekir: "Bu kullanıcının bu kaynağa erişim hakkı var mı?" Cevap ID formatından değil, authorization katmanından gelmelidir.
Yetki kontrolünü controller'a serpmek açık üretir#
Kötü desen genelde şöyle görünür:
const invoice = await db.invoice.findUnique({ where: { id: invoiceId }, }) if (invoice.userId !== session.user.id) { throw new Error('Forbidden') }
Bu kod tek endpoint için çalışabilir. Problem, aynı invoice objesine başka bir endpoint'ten erişildiğinde başlar. PDF export, webhook retry, admin preview, support paneli ve mobil API aynı kontrolü tekrar tekrar yazmaya başladığında bir tanesi unutulur.
Daha güvenli desen, sorgunun kendisine sahiplik filtresi eklemektir:
const invoice = await db.invoice.findFirst({ where: { id: invoiceId, tenantId: session.tenantId, memberships: { some: { userId: session.user.id, role: { in: ['owner', 'finance'] }, }, }, }, }) if (!invoice) { throw new Error('Not found') }
Burada kritik fark şu: yetkisiz kaynak ile var olmayan kaynak aynı davranışı verir. Bu hem veri sızıntısını azaltır hem de test edilebilir bir sözleşme oluşturur.
Test planı: aynı kullanıcı değil, farklı ilişki dene#
IDOR testi sadece iki kullanıcı açıp ID değiştirmek değildir. İlişki modelini bozacak kombinasyonları denemek gerekir.
| Senaryo | Beklenen davranış |
|---|---|
| Aynı tenant, farklı rol | Rol aksiyona yetmiyorsa 403 veya 404 |
| Farklı tenant, aynı obje tipi | Veri hiçbir şekilde dönmemeli |
| Eski üyelik, aktif token | Yetki membership iptalinden sonra düşmeli |
| Read yetkisi, write isteği | Yazma aksiyonu engellenmeli |
| Soft-delete obje | Liste, detay ve export aynı davranmalı |
| Admin route kopyası | Internal endpoint dışarıdan kullanılamamalı |
Bu tabloyu otomasyon testine çevirmek zor değildir. Zor olan, ürün büyürken her yeni endpoint'in bu matrise girmesini zorunlu hale getirmektir.
Tenant izolasyonu en sık include ile bozulur#
ORM kullanan ekiplerde sık gördüğüm hata, ana sorguda tenant filtresi varken ilişkili verinin fazla geniş çekilmesidir.
const project = await db.project.findFirst({ where: { id: projectId, tenantId: session.tenantId, }, include: { apiKeys: true, auditLogs: true, }, })
Bu kod yalnızca project seviyesinde doğru görünür. Ancak apiKeys veya auditLogs başka bir paylaşım modeline sahipse, ilişkiyi de ayrıca filtrelemek gerekir. "Parent doğruysa child da doğrudur" varsayımı, çok kiracılı sistemlerde güvenli değildir.
İyi kapanış kriteri: bulgu değil kök neden kapat#
Bir IDOR bulgusu kapatılırken sadece ilgili endpoint düzeltilirse aynı açık iki hafta sonra başka yerde çıkar. Daha iyi kapanış kriteri şudur:
- Kaynak erişimi için tek bir policy fonksiyonu var mı?
- Policy hem read hem write aksiyonlarını ayırıyor mu?
- Tenant filtresi sorgunun içinde mi?
- Yetkisiz ve var olmayan kaynaklar aynı dış davranışı veriyor mu?
- Test matrisi yeni endpoint eklenirken çalışıyor mu?
- Loglarda "kim, hangi objeye, hangi policy ile erişti" bilgisi var mı?
Kısa sonuç#
API güvenliğinde IDOR'u UUID ile değil, tutarlı authorization kararıyla kapatırsın. ID'ler sızabilir, frontend gizlediğin butonlar çağrılabilir, eski tokenlar beklenmedik yerden dönebilir. Güvenli tasarım, her istekte aynı soruyu sorar: Bu kullanıcı bu objeye bu aksiyonla gerçekten erişmeli mi?
Güven Sınırı Nerede Başlar#
istemci --> edge middleware --> route handler --> policy kontrolü --> sorgu --> DB | | | rate limit oturum ayrıştırma obje kapsamı
Her katman kararı yeniden üretir. Middleware anonim trafiği eleyebilir; ama obje seviyesindeki kontrol sorgunun yanında durmalıdır, satır belleğe geldikten sonra çalışan bir yardımcıda değil.
// Yanlış katman: satır önce çekiliyor, sonra kontrol ediliyor const doc = await db.doc.findUnique({ where: { id } }) if (doc.tenantId !== session.tenantId) throw new Error('Forbidden') // Doğru katman: kontrolün kendisi sorgu const doc = await db.doc.findFirst({ where: { id, tenantId: session.tenantId }, }) if (!doc) throw new Error('Not found')
İlk desende satır, kontrol çalışmadan önceki her loglama, trace ve hata yolu üzerinden geçer. İkincide, izin yoksa veritabanından hiç çıkmaz.
Sıradan Hataları Yakalayan Komutlar#
# İkinci kullanıcının oturumuyla bilinen tüm endpoint'leri tekrar çalıştır while read ep; do curl -s -o /dev/null -w "%{http_code} $ep\n" \ -H "Cookie: session=$USER_B" "$BASE$ep" done < endpoints.txt
Beklenen: A'ya ait her şeyde 403 veya 404. Başka tenant'ın yükünü taşıyan bir 200, uyarı değil bulgudur.
# Aynı prob nesnesi için liste, detay ve export'u karşılaştır curl -s "$BASE/reports?tenantId=$TENANT" | jq '.items[].id' curl -s "$BASE/reports/$RID" | jq '.id' curl -s "$BASE/reports/export?tenantId=$TENANT" | head -5
Nesne export'ta görünüp listede yoksa export yolu policy'i atlamıştır. Olgun API'lerdeki en yaygın sessiz hata budur.
# Üyeliği kaldırılmış kullanıcının erişimi gerçekten düştü mü curl -s -o /dev/null -w "%{http_code}\n" -H "Cookie: session=$OLD_MEMBER" \ "$BASE/projects/$PID"
Bunu üyelik iptalinden hemen sonra yap, token süresinin dolmasından sonra değil. Cevap 200 ise token ilişkiden uzun yaşamış demektir.
Üretimde Tespit#
| Sinyal | Anlamı |
|---|---|
Hesap başına decision=deny patlaması | Numaralandırma denemesi |
| Bir görüntüleyicinin saatte yüzlerce ID'ye dokunması | Okuma değil, ID gezintisi |
| Aynı nesnenin export'ta izinli, detayda yasaklı olması | Endpoint'ler arası policy kayması |
| Üyelik silindikten sonra eski token'ların geçmesi | Rol versiyonu içermeyen cache anahtarı |
Her ret için userId, tenantId, resourceType, resourceId, action, policyName ve decision logla. Token'ı veya gövdeyi asla loglama. Ret logları, sahip olabileceğin en ucuz tuzak telleridir.
Sınırlar#
- Matris bildiğin ilişkileri kapsar. Yeni özellikler policy gözden geçirme adımından geçmezse matrise test edilmemiş satır olarak döner.
- Her şeye 404 dönmek meşru derin bağlantıları bozar ve cache hata ayıklamasını karıştırır. Destek ekibinin "erişim yok" ile "yok" ayrımını yapabilmesi için requestId eşleştir.
- CDN ve token cache'leri yetki iptalinden sonra yanıtı yeniden servis edebilir. Kişiselleştirilmiş yanıtlar
Cache-Control: private, no-storetaşımalıdır. - Bu plan uygulama katmanı hatalarını bulur. Veritabanı kullanıcısının aşırı yetkisini bulmaz; bu yüzden aynı kontrol listesi altyapı gözden geçirmesinde de yer almalıdır.
Cikarilar#
- Rol kontrolü kapıyı korur; obje kontrolü odaları korur.
- Tenant ve sahiplik filtrelerini sorgunun içine yaz, sonrasına değil.
- Yetki hatalarında 403 ve 404 dışarıdan aynı görünmeli.
- Her yeni endpoint çıkış günü test matrisine katılır.
- Policy adı içeren ret logları sessizliği sinyale çevirir.
Matrisi CI'a Bağlamak#
Matris ancak çalışırsa işe yarar. Asgari sürüm, seed edilmiş bir staging veritabanına karşı çalışan (kullanıcı, endpoint, yük, beklenen durum) tablosudur:
| Fikstür | Endpoint | Metod | Yoklanan alan | Beklenen |
|---|---|---|---|---|
| owner-a | /invoices/inv_b | GET | - | 404 |
| viewer-a | /invoices/inv_a | PATCH | amount | 403 |
| ex-member | /projects/p1 | GET | - | 404 |
| support | /admin/refund | POST | userId | 403 |
| stranger | /search?q=invoice | GET | results | 200, sadece kendi tenant'ı |
Route, policy veya ORM şemasını değiştiren her pull request'te çalıştır. Yeni route'a yeni satır yok demek review'da reddedilsin demek. Burada satın aldığın hata modu kurnaz bir saldırgan değil; altı ay sonra kopyalanmış servis metodu ekleyecek yeni bir geliştirici.
Her Ret Logunda Olması Gereken Alanlar#
- Kullanıcı raporunu tek bir kararla eşleştirmek için
requestId - Hangi kuralın tetiklendiğini görmek için
policyName - ID'ler hassassa hash'lenmiş
resourceTypeveresourceId - Cross-tenant retlerde
actorTenantIdileresourceTenantId
Ret logunu, isteğin senkron yolunda yazılabilecek kadar küçük tut. Loglama kendisi kuyruk gerektiriyorsa kuyruk, tam sistem saldırı altındayken kayıt düşürür.
Token Ömrü Notu#
Kısa ömürlü erişim token'ı artı policy katmanında iptal kontrolü gerçekçi orta yoldur. Uzun süreli stateless JWT'ler "kaldırılmış kullanıcı erişimi sürdürür" durumunu istisna değil varsayılan yapar. Ne seçersen seç, kaldırılan üye satırı gerçeğin göründüğü yerdir.
Unutulan İki Yer#
GraphQL alan çözümleyicileri#
Cache'ten nesne döndürüp üyeliği yeniden doğrulamayan bir resolver, toplu sorgularla veri sızdırır: tek sorgu elli tenant'tan elli nesneye açılabilir. Saklanmış nesneye dokunan her resolver, REST yolundakiyle aynı obje kapsamı filtresini taşımalıdır. Policy fonksiyonunu paylaşmak tek sürdürülebilir seçenektir.
Report: { export: async (parent, _args, ctx) => { const allowed = await policy.can(ctx.user, 'export', parent.id) if (!allowed) throw new ForbiddenError() return renderExport(parent.id) }, }
Webhook'lar ve arka plan işleri#
Secret'ınla imzalanmış giden bir webhook, alıcı tarafından güvenilir sayılır. Yükü hazırlayan iş nesneyi tenant kapsamı olmadan okuduysa alıcı yanlış tenant'ın verisini alır ve imzan onu resmi gösterir. Çözüm, istek yoluyla aynı disiplin: işin sorgusu kiracıyı global varsayılan yerine tetikleyen olaydan taşır.
await db.invoice.update({ where: { id: event.invoiceId, tenantId: event.tenantId }, data: { status: 'paid' }, })
Performans Notu#
Obje kapsamlı sorgular bir join veya ek filtre ekler. Bu, alternatiften ucuzdur: yük sonrası kontroller satırları belleğe taşır, yalnızca middleware kontrolleri her handler'a kopyalanır ve ikisi de loglamayı iki yerden yapmanı ister. (tenantId, id) üzerinde kompozit indeks, kapsamlı sorguyu kapsalı olanla aynı maliyet bandına çeker.
Son Kapı#
Çıkış öncesi orijinal altı soruyu yeni kod için tekrar yanıtla, sonra üç soru daha: bu policy kayarsa hangi resolver sızdırır, hangi export kontrolü atlar, hangi bayat token hâlâ geçer. Bu cevaplar birinin kafasında değil testte yaşıyorsa çözüm bir test uzağındadır.
Çalışılmış Bir Örnek#
Destek mühendisinin müşterinin faturasını okuması ama asla yazmaması gerektiğini varsayalım. Bunu "admin her şeyi yapar" olarak değil, açıkça modelle:
const allowed = await policy.check({ actor: session.user, action: 'invoice:read', resource: { type: 'invoice', id: invoiceId, tenantId }, })
if (!allowed) { audit.deny({ userId: session.user.id, tenantId, resourceId: invoiceId, action: 'invoice:read' }) throw new NotFoundError() }
Aynı kontrol şimdi invoice:write PATCH handler'ını, invoice:export export handler'ını ve invoice:admin-view destek paneli aksiyonunu sarmalar. Tek policy modülü, tek log formatı, rol başına tek test fikstürü. Yeni rol çıktığında policy'e bir satır, matrise bir satır eklersin; CI kapısı yüzeyin geri kalanının davrandığını söyler.
Kısacası#
Pratikte "yetkilendirme çalışıyor" demek: tek karar fonksiyonu, kapsamı sorgular, reddedilen ile var olmayan için aynı dış davranış, ve her deploy'da koşan matris. Gerisi süs. Bu alışkanlık tek bir kontrolden önemlidir.
Bu kapıyı her merge'de çalıştır ki çapraz tenant okumaları muhtemel değil, imkânsız kalsın.
Her route, her rol, her export aynı filtreyi görüyorsa yetki modeli dağılmaz.
Bu satır her yeni endpoint ile büyür ve büyüme onun görevidir.
Minimum Kabul Kriteri#
- Liste, detay, arama ve export aynı policy fonksiyonunu çağırır.
- Ret logları
policyNametaşır. - Kaldırılan üyenin eski token'ı bir sonraki istekte düşer.
Ne düşünüyorsun?
Tepki bırakarak geri bildirim ver