CodeAuditAgent
Tüm yazılar
  • Güvenlik
  • OWASP
  • Erişim Kontrolü

IDOR ve Hatalı Erişim Kontrolü: Pratik Bir Rehber

IDOR ve hatalı erişim kontrolü REST, GraphQL ve Next.js route handler'larına nasıl sızar, nasıl test edilir ve gerçek projelerde hangi düzeltmeler işe yarar?

· 7 dk okuma · Lina Source LLC

Hatalı erişim kontrolü, her framework yükseltmesinden sağ çıkan hata sınıfıdır. ORM'iniz SQL'i, şablon motorunuz HTML'i kaçışlar; ama yığındaki hiçbir katman 4812 numaralı faturanın Bob'a değil Alice'e ait olduğunu bilmez. Bu bilgi sizin kodunuzda yaşar ve tek bir handler onu uygulamayı unuttuğunda, giriş yapmış herhangi bir kullanıcı başkasının verisini okuyabilir veya değiştirebilir.

En yaygın biçimi güvensiz doğrudan nesne referansı, yani IDOR'dur: istemci bir tanımlayıcı gönderir, sunucu kaydı bu tanımlayıcıyla yükler ve çağıranın bu kaydı görmeye yetkisi olup olmadığını kimse kontrol etmez. İstismar etmek için özel bir araç gerekmez. Bir tarayıcı, ikinci bir hesap ve URL'de değiştirilmiş bir sayı yeterlidir. Tehlikeli fonksiyon çağrılarını arayan tarayıcılar bunu nadiren yakalar, çünkü açıklı kodda tehlikeli hiçbir şey yoktur: tamamen sıradan bir veritabanı sorgusunda yalnızca bir koşul eksiktir.

Karşılaşacağınız üç CWE

  • CWE-639, kullanıcı kontrollü anahtar üzerinden yetkilendirme atlatma: klasik IDOR. Kayıt, saldırganın kontrol ettiği bir ID ile seçilir ve sahiplik hiç kontrol edilmez.
  • CWE-862, eksik yetkilendirme: handler hiçbir yetkilendirme kontrolü yapmaz. Çoğu zaman erişilemez olduğu varsayılan bir yönetici veya dahili endpoint'tir.
  • CWE-285, hatalı yetkilendirme: bir kontrol vardır ama yanlıştır. Yanlış alanı kontrol eder, yazma işleminde okuma iznine bakar ya da istemcinin gönderdiği bir role güvenir.

Bu ayrım, hatayı düzeltirken önem kazanır. Eksik kontrol, bir kontrol eklemek demektir; hatalı kontrol ise kimin neyi yapabileceğine dair modelin yanlış olduğu ve aynı hatanın muhtemelen başka yerlerde de tekrarlandığı anlamına gelir. Hangisini bulursanız bulun, kaydı kapatmadan önce benzerlerini arayın. Erişim kontrolü hataları nadiren tekildir; ekibin handler'dan handler'a kopyaladığı kalıpları izler.

Next.js route handler'ında nasıl ortaya çıkar

Kalıbın en yaygın hâli şöyledir. Handler kullanıcının kimliğini doğrular, bu da güvenlik gibi hissettirir; ardından kaydı yalnızca ID'siyle yükler. Oturum kontrolü kimin çağırdığını yanıtlar; bu çağıranın bu faturayı görüp göremeyeceğini ise hiçbir şey yanıtlamaz.

// app/api/invoices/[id]/route.ts  (vulnerable)
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { id } = await params;
  // Any signed-in user can read any invoice by changing the ID
  const invoice = await db.invoice.findUnique({ where: { id } });
  return NextResponse.json(invoice);
}

Çözüm, sahipliği unutulabilecek ayrı bir adım olarak değil, sorgunun kendisinin bir parçası olarak ele almaktır. Kayıt çağırana ait değilse veritabanı hiçbir şey döndürmez ve handler 404 ile yanıt verir.

// app/api/invoices/[id]/route.ts  (fixed)
export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { id } = await params;
  const invoice = await db.invoice.findFirst({
    where: { id, userId: session.user.id },
  });
  if (!invoice) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }
  return NextResponse.json(invoice);
}

403 yerine 404 döndürmek bilinçli bir tercihtir. 403, kaydın var olduğunu doğrular; bu da saldırganın okuyamadığı durumlarda bile geçerli ID'leri numaralandırmasına olanak tanır. Aynı durum yanıt süreleri ve hata mesajları için de geçerlidir: başkasına ait bir kayıt için dönen yanıt, hiç var olmamış bir kayıt için dönen yanıttan ayırt edilemez olmalıdır.

REST: unutulan endpoint'ler

Ekipler genellikle ID ile yapılan bariz GET isteğini korur. Hatalar diğer HTTP metotlarında ve API'nin kenar noktalarında saklanır:

  • Sahiplik kontrolü eklenmeden önce GET handler'ından kopyalanmış PATCH ve DELETE handler'ları.
  • /projects/:projectId/tasks/:taskId gibi iç içe route'lar: proje kontrol edilir ama görev yalnızca taskId ile yüklenir ve başka bir projeye ait olabilir.
  • Bir ID dizisi kabul edip yalnızca ilkini kontrol eden toplu işlem endpoint'leri.
  • Çoğu zaman kendi, daha zayıf kontrollerine sahip ayrı bir servis üzerinden çalışan dosya indirmeleri ve dışa aktarma işleri.
  • İstek gövdesinden ownerId, organizationId veya role kabul edip bunları doğrudan veritabanına yazan güncelleme yükleri (mass assignment).

GraphQL saldırı yüzeyini genişletir

GraphQL'de aynı nesneye birçok yoldan ulaşılabilir. invoice(id) üzerindeki sorgu düzeyinde bir kontrol, aynı faturaya customer { invoices }, bir node(id) sorgusu ya da bir mutation'ın dönüş tipi üzerinden de ulaşılabiliyorsa işe yaramaz. Nesne döndüren her resolver bir giriş noktasıdır. DataLoader gibi toplama katmanları bir tuzak daha ekler: yalnızca ID ile anahtarlanmış bir loader, kayıtları her görüntüleyene seve seve döndürür ve istekler arasında paylaşılıyorsa önbelleği bir kullanıcının verisini sonraki bir isteğe sunabilir.

Güvenilir yaklaşım, yetkilendirmeyi resolver'ların kendisinde değil, resolver'ların çağırdığı veri katmanında yapmaktır. Bir faturaya giden her yol, görüntüleyeni alıp sorguyu kapsamlayan tek bir fonksiyondan geçiyorsa yeni bir alan veya ilişki eklemek bu kontrolü atlatamaz. Mutation girdilerini de kontrol edin: bir input tipindeki ownerId gibi bir alan, kayıtların başkasına devredilmesine açık davetiyedir. Son olarak, introspection ve hata mesajlarının şemanızı açığa çıkardığını unutmayın; saldırganların sunduğunuz her alanı ve ilişkiyi bildiğini varsayın.

Python'da aynı hata

FastAPI ve SQLAlchemy'de de yapı aynıdır. Açıklı sürüm db.get(Document, doc_id) çağırır; düzeltilmiş sürüm aynı ifadede sahibe göre filtreler.

from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session

app = FastAPI()

@app.get("/documents/{doc_id}")
def get_document(
    doc_id: int,
    user: User = Depends(current_user),
    db: Session = Depends(get_db),
):
    # Vulnerable: doc = db.get(Document, doc_id)
    doc = db.scalar(
        select(Document).where(
            Document.id == doc_id,
            Document.owner_id == user.id,
        )
    )
    if doc is None:
        raise HTTPException(status_code=404, detail="Not found")
    return doc

Kalıcı düzeltmeler

Her sorguyu sahibe veya tenant'a göre kapsamlayın

Kullanıcı veya organizasyon ID'sini her okuma ve yazma işleminin WHERE koşuluna koyun. Böylece yetkilendirme, incelemede kolayca görülebilen bir sorgu özelliğine dönüşür. Çok kiracılı (multi-tenant) uygulamalarda Postgres row-level security, tenant sınırını ikinci bir katman olarak zorlayabilir; böylece unutulmuş bir filtre başka bir müşterinin satırlarını değil, boş sonuç döndürür. Aynı kapsamlama yazma işlemleri için de geçerlidir. Bir güncelleme, önce okuyup kontrol edip sonra ayrıca yazan ve yarış durumuna açık bir akış yerine, hem ID hem sahip koşuluyla filtrelenmiş tek bir ifade olmalıdır; örneğin her iki koşulu içeren bir updateMany ve ardından tam olarak bir satırın değiştiğinin kontrolü.

Kararı merkezileştirin

Dağınık if ifadeleri zamanla birbirinden uzaklaşır. Her kaynak için bir tane olmak üzere küçük bir yardımcı fonksiyon seti, kuralı tek bir yerde tutar ve yardımcı çağrısı içermeyen bir handler'ı hemen göze çarpar hâle getirir.

// lib/authz.ts
type Role = "owner" | "member" | "viewer";
type Action = "read" | "update" | "delete";

const policy: Record<Role, ReadonlySet<Action>> = {
  owner: new Set<Action>(["read", "update", "delete"]),
  member: new Set<Action>(["read", "update"]),
  viewer: new Set<Action>(["read"]),
};

export class NotFoundError extends Error {}

export async function requireProject(
  userId: string,
  projectId: string,
  action: Action
) {
  const membership = await db.membership.findFirst({
    where: { userId, projectId },
    include: { project: true },
  });
  // Deny by default: no membership or no permission looks the same
  if (!membership || !policy[membership.role as Role]?.has(action)) {
    throw new NotFoundError();
  }
  return membership.project;
}

Varsayılan olarak reddedin

Bilinmeyen roller, eksik üyelikler ve beklenmeyen eylemlerin tümü reddedilmelidir. Middleware destekleyen framework'lerde her şey için kimlik doğrulamayı zorunlu tutun ve herkese açık route'ları tersine değil, açıkça işaretleyin. Yeni bir route, biri aksine karar verene kadar kilitli olmalıdır. Rolü, tenant'ı veya kullanıcı ID'sini asla istek gövdesinden ya da istemcinin ayarladığı bir başlıktan almayın; bunları her seferinde sunucuda doğrulanmış oturumdan türetin.

UUID gibi rastgele tanımlayıcılar kullanmaya değer, ama bir çözüm değildir. ID'ler URL'ler, loglar, paylaşılan bağlantılar ve referrer başlıkları üzerinden sızar. Onları yalnızca numaralandırmayı yavaşlattıkları ölçüde tahmin edilemez sayın; asla erişim kontrolünün yerine koymayın.

Nasıl test edilir

IDOR testi basit ve tekrarlayıcıdır; bu yüzden bir kez elle yaptıktan sonra otomatikleştirmeye değer. İşe bir envanterle başlayın: istek gövdelerinde, sorgu dizelerinde ve başlıklarda gizlenmiş ID'ler dahil, tanımlayıcı kabul eden her route'u, resolver'ı ve arka plan işini listeleyin.

  • İdeal olarak iki ayrı organizasyonda A ve B adında iki hesap oluşturun. A olarak bir kayıt oluşturun ve ID'sini not edin.
  • Bu ID'ye başvuran her isteği B'nin oturumuyla yeniden gönderin: GET, PATCH, DELETE, indirmeler, dışa aktarmalar ve kayda dokunan her GraphQL sorgusu veya mutation'ı.
  • Hepsinden 404 bekleyin. Her 200 yanıtı ve kaydın varlığını doğrulayan her 403 bir bulgudur.
  • Elle yaptığınız kontrolü her kaynak için bir entegrasyon testine dönüştürün; böylece kapsamlı sorgu içermeyen yeni bir handler CI'da başarısız olur.
  • findUnique({ where: { id } }) veya db.get(Model, id) gibi yalnızca birincil anahtarla yapılan sorguları grep ile arayın ve her birinin gerekçesini ortaya koyun.

Kod incelemesi, testlerin kaçırdığını yakalar; çünkü o route için kimse test yazmamış olsa bile eksik kontrol kaynak kodda görünür. CodeAuditAgent herkese açık bir GitHub deposunu veya yapıştırılmış bir kod parçasını okur ve erişim kontrolü açıklarını CWE, alıntılanmış satır, istismar senaryosu ve önerilen yamayla birlikte raporlar; bu da tüm handler'ları tek seferde ikinci bir gözden geçirmenin hızlı bir yoludur.

Kısa bir kontrol listesi

  • İstemciden gelen bir ID alan her sorgu, çağıranın kullanıcısına veya tenant'ına göre de filtreleme yapar.
  • Yetkilendirme, kopyala-yapıştır if ifadelerinde değil, ortak yardımcı fonksiyonlarda veya veri katmanında bulunur.
  • Bilinmeyen durumlar reddedilir; herkese açık route'lar açıkça belirtilmiş istisnalardır.
  • Yazma işlemleri, toplu ve iç içe route'lar dahil, okumalar kadar özenle kontrol edilir.
  • Her kaynak için iki hesaplı testler vardır ve CI'da çalışır.