CodeAuditAgent
Alle Artikel
  • Sicherheit
  • OWASP
  • Zugriffskontrolle

IDOR und Broken Access Control: Ein Praxisleitfaden

Wie IDOR und fehlerhafte Zugriffskontrolle in REST-, GraphQL- und Next.js-Handler gelangen, wie Sie darauf testen und welche Fixes in der Praxis halten.

· 7 Min. Lesezeit · Lina Source LLC

Fehlerhafte Zugriffskontrolle (Broken Access Control) ist die Fehlerklasse, die jedes Framework-Upgrade überlebt. Ihr ORM maskiert SQL, Ihre Template-Engine maskiert HTML, aber nichts im Stack weiß, dass Rechnung 4812 Alice gehört und nicht Bob. Dieses Wissen steckt in Ihrem Code, und sobald ein einzelner Handler vergisst, es anzuwenden, kann jeder angemeldete Benutzer fremde Daten lesen oder ändern.

Die häufigste Form ist die unsichere direkte Objektreferenz, kurz IDOR: Der Client sendet eine Kennung, der Server lädt den Datensatz mit dieser Kennung, und niemand prüft, ob der Aufrufer ihn sehen darf. Für einen Angriff braucht es keine speziellen Werkzeuge. Ein Browser, ein zweites Konto und eine geänderte Zahl in der URL genügen. Scanner, die nach gefährlichen Funktionsaufrufen suchen, finden den Fehler selten, weil der verwundbare Code nichts Gefährliches enthält: Einer völlig gewöhnlichen Datenbankabfrage fehlt schlicht eine Bedingung.

Die drei CWEs, denen Sie begegnen werden

  • CWE-639, Umgehung der Autorisierung über einen benutzerkontrollierten Schlüssel: die klassische IDOR. Der Datensatz wird über eine ID ausgewählt, die der Angreifer kontrolliert, und die Eigentümerschaft wird nie geprüft.
  • CWE-862, fehlende Autorisierung: Der Handler führt überhaupt keine Berechtigungsprüfung durch. Oft handelt es sich um einen Admin- oder internen Endpunkt, der als unerreichbar galt.
  • CWE-285, unzureichende Autorisierung: Eine Prüfung existiert, ist aber falsch. Sie prüft das falsche Feld, prüft bei einem Schreibzugriff nur die Leseberechtigung oder vertraut einer Rolle, die der Client mitschickt.

Die Unterscheidung ist wichtig, wenn Sie den Fehler beheben. Eine fehlende Prüfung bedeutet, dass Sie eine hinzufügen; eine falsche Prüfung bedeutet, dass das Modell, wer was darf, fehlerhaft ist, und derselbe Fehler steckt vermutlich auch an anderen Stellen. Wenn Sie eine der beiden Varianten finden, suchen Sie nach Geschwistern, bevor Sie das Ticket schließen. Zugriffskontrollfehler sind selten Einzelfälle; sie folgen den Mustern, die ein Team von Handler zu Handler kopiert.

So entsteht der Fehler in einem Next.js-Route-Handler

Hier ist das Muster in seiner häufigsten Form. Der Handler authentifiziert den Benutzer, was sich nach Sicherheit anfühlt, und lädt den Datensatz dann allein über seine ID. Die Session-Prüfung beantwortet, wer aufruft; nichts beantwortet, ob dieser Aufrufer diese Rechnung sehen darf.

// app/api/invoices/[id]/route.ts  (verwundbar)
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;
  // Jeder angemeldete Benutzer kann durch Ändern der ID jede Rechnung lesen
  const invoice = await db.invoice.findUnique({ where: { id } });
  return NextResponse.json(invoice);
}

Die Lösung besteht darin, die Eigentümerschaft zum Bestandteil der Abfrage selbst zu machen statt zu einem separaten Schritt, den man vergessen kann. Gehört der Datensatz nicht dem Aufrufer, liefert die Datenbank nichts, und der Handler antwortet mit 404.

// app/api/invoices/[id]/route.ts  (behoben)
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);
}

Dass 404 statt 403 zurückgegeben wird, ist Absicht. Ein 403 bestätigt, dass der Datensatz existiert, und erlaubt einem Angreifer so, gültige IDs aufzuzählen, selbst wenn er sie nicht lesen kann. Dasselbe gilt für Antwortzeiten und Fehlermeldungen: Die Antwort auf einen fremden Datensatz sollte sich nicht von der Antwort auf einen Datensatz unterscheiden lassen, der nie existiert hat.

REST: die Endpunkte, die gern vergessen werden

Teams schützen meist den offensichtlichen GET-Aufruf per ID. Die Fehler verstecken sich in den anderen HTTP-Methoden und an den Rändern der API:

  • PATCH- und DELETE-Handler, die vom GET-Handler kopiert wurden, bevor dort die Eigentümerprüfung ergänzt wurde.
  • Verschachtelte Routen wie /projects/:projectId/tasks/:taskId, bei denen das Projekt geprüft, die Aufgabe aber allein über taskId geladen wird und womöglich zu einem anderen Projekt gehört.
  • Bulk-Endpunkte, die ein Array von IDs annehmen und nur die erste prüfen.
  • Datei-Downloads und Export-Jobs, die oft über einen separaten Dienst mit eigenen, schwächeren Prüfungen laufen.
  • Update-Payloads, die ownerId, organizationId oder role aus dem Request-Body übernehmen und direkt in die Datenbank schreiben (Mass Assignment).

GraphQL vergrößert die Angriffsfläche

In GraphQL lässt sich dasselbe Objekt über viele Pfade erreichen. Eine Prüfung auf Query-Ebene bei invoice(id) hilft nicht, wenn dieselbe Rechnung auch über customer { invoices }, einen node(id)-Lookup oder den Rückgabetyp einer Mutation erreichbar ist. Jeder Resolver, der ein Objekt zurückgibt, ist ein Einstiegspunkt. Batching-Schichten wie DataLoader bringen eine weitere Falle mit: Ein Loader, der nur nach ID schlüsselt, liefert Datensätze bereitwillig an jeden Betrachter aus, und sein Cache kann die Daten eines Benutzers an eine spätere Anfrage ausliefern, wenn er über Anfragen hinweg geteilt wird.

Der verlässliche Ansatz ist, in der Datenschicht zu autorisieren, die die Resolver aufrufen, nicht in den Resolvern selbst. Wenn jeder Pfad zu einer Rechnung über eine einzige Funktion führt, die den Betrachter entgegennimmt und die Abfrage eingrenzt, kann ein neues Feld oder eine neue Beziehung sie nicht umgehen. Prüfen Sie außerdem die Eingaben von Mutations: Ein Feld wie ownerId in einem Input-Typ ist eine Einladung, Datensätze neu zuzuweisen. Denken Sie schließlich daran, dass Introspection und Fehlermeldungen Ihr Schema offenlegen. Gehen Sie also davon aus, dass Angreifer jedes Feld und jede Beziehung kennen, die Sie bereitstellen.

Derselbe Fehler in Python

In FastAPI mit SQLAlchemy sieht das Muster genauso aus. Die verwundbare Version ruft db.get(Document, doc_id) auf; die behobene Version filtert in derselben Anweisung nach dem Eigentümer.

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),
):
    # Verwundbar: 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

Fixes, die halten

Jede Abfrage auf Eigentümer oder Mandant eingrenzen

Nehmen Sie die Benutzer- oder Organisations-ID in die WHERE-Klausel jedes Lese- und Schreibzugriffs auf. So wird die Autorisierung zu einer Eigenschaft der Abfrage, die im Review leicht zu erkennen ist. In mandantenfähigen Anwendungen kann Postgres Row-Level Security die Mandantengrenze als zweite Schicht durchsetzen, sodass ein vergessener Filter nichts zurückgibt statt der Zeilen eines anderen Kunden. Dieselbe Eingrenzung gilt für Schreibzugriffe. Ein Update sollte eine einzige Anweisung sein, die sowohl nach ID als auch nach Eigentümer filtert, etwa updateMany mit beiden Bedingungen und anschließender Prüfung, dass genau eine Zeile geändert wurde, und nicht ein Lesen, eine Prüfung und ein separates Schreiben, zwischen denen eine Race Condition entstehen kann.

Die Entscheidung zentralisieren

Verstreute if-Anweisungen driften auseinander. Eine kleine Menge von Hilfsfunktionen, eine pro Ressource, hält die Regel an einem Ort und lässt einen Handler ohne Helper-Aufruf sofort auffallen.

// 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 },
  });
  // Standardmäßig verweigern: keine Mitgliedschaft und keine Berechtigung sehen gleich aus
  if (!membership || !policy[membership.role as Role]?.has(action)) {
    throw new NotFoundError();
  }
  return membership.project;
}

Standardmäßig verweigern

Unbekannte Rollen, fehlende Mitgliedschaften und unerwartete Aktionen sollten allesamt in einer Ablehnung enden. In Frameworks mit Middleware verlangen Sie Authentifizierung für alles und kennzeichnen öffentliche Routen explizit, nicht umgekehrt. Eine neue Route sollte gesperrt sein, bis jemand bewusst etwas anderes entscheidet. Übernehmen Sie Rolle, Mandant oder Benutzer-ID niemals aus dem Request-Body oder einem vom Client gesetzten Header; leiten Sie sie jedes Mal serverseitig aus der verifizierten Session ab.

Zufällige Kennungen wie UUIDs sind sinnvoll, aber sie sind kein Fix. IDs sickern über URLs, Logs, geteilte Links und Referrer-Header durch. Betrachten Sie sie nur insofern als nicht erratbar, als sie das Aufzählen verlangsamen, niemals als Zugriffsprüfung.

So testen Sie darauf

IDOR-Tests sind einfach und repetitiv, weshalb es sich lohnt, sie zu automatisieren, sobald Sie sie einmal von Hand durchgeführt haben. Beginnen Sie mit einer Bestandsaufnahme: Listen Sie jede Route, jeden Resolver und jeden Hintergrundjob auf, der eine Kennung entgegennimmt, einschließlich IDs, die in Request-Bodys, Query-Strings und Headern versteckt sind.

  • Legen Sie zwei Konten an, A und B, idealerweise in zwei getrennten Organisationen. Erstellen Sie als A einen Datensatz und notieren Sie seine ID.
  • Wiederholen Sie jede Anfrage, die diese ID referenziert, mit der Session von B: GET, PATCH, DELETE, Downloads, Exporte sowie jede GraphQL-Query oder -Mutation, die den Datensatz berührt.
  • Erwarten Sie bei allen ein 404. Jedes 200 und jedes 403, das die Existenz bestätigt, ist ein Befund.
  • Machen Sie aus der manuellen Prüfung einen Integrationstest pro Ressource, damit ein neuer Handler ohne eingegrenzte Abfrage in der CI fehlschlägt.
  • Suchen Sie per grep nach Lookups allein über den Primärschlüssel, etwa findUnique({ where: { id } }) oder db.get(Model, id), und begründen Sie jeden einzelnen.

Code-Review findet, was Tests übersehen, denn die fehlende Prüfung ist im Quellcode sichtbar, auch wenn niemand einen Test für diese Route geschrieben hat. CodeAuditAgent liest ein öffentliches GitHub-Repository oder ein eingefügtes Code-Snippet und meldet Lücken in der Zugriffskontrolle mit CWE, zitierter Zeile, Exploit-Szenario und vorgeschlagenem Patch. So erhalten Sie schnell einen zweiten Blick auf alle Handler auf einmal.

Eine kurze Checkliste

  • Jede Abfrage, die eine vom Client gelieferte ID entgegennimmt, filtert auch nach dem Benutzer oder Mandanten des Aufrufers.
  • Die Autorisierung liegt in gemeinsamen Hilfsfunktionen oder in der Datenschicht, nicht in kopierten if-Anweisungen.
  • Unbekannte Fälle werden abgelehnt; öffentliche Routen sind die explizite Ausnahme.
  • Schreibzugriffe werden ebenso sorgfältig geprüft wie Lesezugriffe, einschließlich Bulk- und verschachtelter Routen.
  • Für jede Ressource gibt es Tests mit zwei Konten, die in der CI laufen.