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ı.

9 dk okuma
ibrahimsql
1.766 kelime

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.

SenaryoBeklenen davranış
Aynı tenant, farklı rolRol aksiyona yetmiyorsa 403 veya 404
Farklı tenant, aynı obje tipiVeri hiçbir şekilde dönmemeli
Eski üyelik, aktif tokenYetki membership iptalinden sonra düşmeli
Read yetkisi, write isteğiYazma aksiyonu engellenmeli
Soft-delete objeListe, 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#

SinyalAnlamı
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çmesiRol 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-store taşı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ürEndpointMetodYoklanan alanBeklenen
owner-a/invoices/inv_bGET-404
viewer-a/invoices/inv_aPATCHamount403
ex-member/projects/p1GET-404
support/admin/refundPOSTuserId403
stranger/search?q=invoiceGETresults200, 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ş resourceType ve resourceId
  • Cross-tenant retlerde actorTenantId ile resourceTenantId

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ı policyName taşır.
  • Kaldırılan üyenin eski token'ı bir sonraki istekte düşer.
---
Bu yazıyı paylaş:
TwitterLinkedInFacebook

Ne düşünüyorsun?

Tepki bırakarak geri bildirim ver

İlgili Yazılar