Eine Insecure Direct Object Reference (IDOR) liegt überall dort vor, wo Ihre Anwendung einen Bezeichner vom Client entgegennimmt und den passenden Datensatz zurückgibt oder ändert, ohne zu prüfen, ob dieser Benutzer darauf zugreifen darf. In der API-Welt heißt derselbe Fehler Broken Object Level Authorization (BOLA) und steht auf Platz 1 der OWASP API Security Top 10 von 2023. Um ihn zu verhindern, braucht es bei jedem Objektzugriff eine Autorisierungsprüfung, die in der gesamten Anwendung greift und durch Tests abgesichert ist.
Woraus eine IDOR besteht und wo sie sich versteckt
Das OWASP IDOR Cheat Sheet zerlegt eine IDOR in drei Bestandteile: ein Objekt (Konto, Dokument, Ticket, Transaktion), eine Referenz darauf (ID, UUID, Kontonummer, Token oder Slug) und eine fehlende Prüfung auf Objektebene. Der dritte Punkt ist der eigentliche Fehler.
Die Referenz steckt nicht immer als /users/123 in der URL. Sie kann in einem versteckten Formularfeld wie user_id stehen, in einem Query-String, in einem Request-Header oder in einer JSON-Payload. Auch ein Dateiname kann eine Referenz sein: Funktioniert GET /documents/annual-report.pdf, probieren Sie einen anderen gültigen Dokumentnamen aus.
Der OWASP-API-Security-Eintrag zu BOLA stuft die Ausnutzbarkeit als einfach, die Verbreitung als weit und die Auffindbarkeit als einfach ein. API-Server verfolgen den Zustand des Clients meist nicht und verlassen sich daher auf die vom Client gesendeten IDs, um zu entscheiden, welches Objekt sie laden. Die Beispielszenarien dort:
- Ein Endpunkt für Shop-Analysen der Form
/shops/{shopName}/revenue_data.jsonlegt in Kombination mit einem zweiten Endpunkt, der alle Shop-Namen auflistet, die Umsatzdaten Tausender Shops für ein einfaches Skript offen. - Eine Fahrzeug-API akzeptiert eine VIN, prüft aber nie, ob das Fahrzeug dem angemeldeten Benutzer gehört.
- Eine GraphQL-Mutation
deleteReportslöscht jede Dokument-ID, die sie erhält.
Der letzte Fall ist ein Löschvorgang. Testen Sie daher Änderungen und Löschungen genauso sorgfältig wie die Seite, die den Datensatz anzeigt.
Testen mit zwei Konten
Legen Sie Benutzer A und Benutzer B an, geben Sie jedem eigene Objekte (Dokumente, Tickets, Rechnungen), authentifizieren Sie sich als A und senden Sie die Requests mit den Bezeichnern von B erneut.
- Erstellen Sie zwei Konten mit unterschiedlichen Autorisierungsbereichen.
- Erstellen Sie Objekte, die jeweils einem der beiden Benutzer gehören.
- Melden Sie sich als Benutzer A an und erfassen Sie jeden Request, der eine Objektreferenz enthält.
- Setzen Sie die Referenzen von Benutzer B in Pfad, Query, Body und Headern ein.
- Prüfen Sie, dass die Anwendung den Zugriff jedes Mal verweigert.
Decken Sie Lesen, Anlegen, Ändern, Löschen, Export und administrative Aktionen ab. Überspringen Sie keinen Endpunkt, nur weil seine Bezeichner zufällig aussehen. Laut Cheat Sheet muss der Zugriff verweigert werden, egal ob der Bezeichner vorhersagbar oder nicht erratbar ist.
Als Regressionstest umsetzen
Funktioniert der manuelle Durchlauf, machen Sie daraus Code. Eine Skizze in pytest, die Fixtures voraussetzt, die eine angemeldete Session für Benutzer A und die ID eines Dokuments von B liefern. Jeder Request muss ansonsten gültig sein und einen wohlgeformten Body haben, damit ein 400 oder 415 aus der Eingabevalidierung den Test nicht aus dem falschen Grund bestehen lässt:
import os
import pytest
BASE_URL = os.environ["APP_BASE_URL"] # from your test config
DENIED = 404 # your chosen response for foreign IDs
ENDPOINTS = [
("GET", "/documents/{id}", None),
("PUT", "/documents/{id}", {"title": "changed"}),
("DELETE", "/documents/{id}", None),
("GET", "/documents/{id}/export", None),
("POST", "/documents/{id}/comments", {"text": "hello"}),
]
@pytest.mark.parametrize("method,path,body", ENDPOINTS)
def test_a_cannot_touch_bs_document(method, path, body, session_a, b_document_id):
url = BASE_URL + path.format(id=b_document_id)
r = session_a.request(method, url, json=body)
assert r.status_code == DENIED
Entscheiden Sie sich bewusst zwischen 403 und 404. Ein 403 bei einer fremden ID bestätigt, dass die ID existiert, und hilft Angreifern bei der Enumeration. Wofür Sie sich auch entscheiden: Prüfen Sie ausschließlich auf diesen einen Code. Erzeugen Sie die Endpunktliste aus Ihrer Routing-Tabelle, damit neue Endpunkte automatisch erscheinen, und blockieren Sie Deployments, wenn die Test-Suite fehlschlägt.
GraphQL erfordert zwei zusätzliche Fälle. Fragen Sie auf jeder Ebene des Baums eine fremde ID ab, um zu sehen, ob verschachtelte Resolver die Prüfung des übergeordneten Objekts wiederholen. Senden Sie bei Batch-Operationen wie dem oben genannten reportKeys-Array eine Anfrage, die eigene und fremde IDs mischt, und prüfen Sie, ob der gesamte Aufruf fehlschlägt oder das fremde Element übersprungen wird.
Die Abfrage auf den Benutzer eingrenzen
Prüfen Sie die Autorisierung für jedes Objekt, auf das ein Benutzer zugreifen möchte, und nutzen Sie dafür den Mechanismus Ihres Frameworks. So ist die Prüfung strukturell verankert und hängt nicht davon ab, dass jeder Entwickler daran denkt. Das direkteste Muster ist, Objekte nur innerhalb der Menge nachzuschlagen, die der aktuelle Benutzer erreichen darf. Die Rails-Variante:
# vulnerable, searches all projects
@project = Project.find(params[:id])
# secure, searches projects related to the current user
@project = @current_user.projects.find(params[:id])
Der Spring-Boot-Handler im Cheat Sheet ruft findById(id) auf und liefert damit jedes Dokument an jeden, der eine ID erraten oder beschaffen kann. Das folgende Snippet ist unser eigenes Beispiel und nicht der Code von OWASP. Es setzt eine abgeleitete Spring-Data-Abfrage auf einem Feld ownerId voraus, und currentUser() steht für Ihren Helfer, der den vertrauenswürdigen Authentifizierungskontext ausliest:
@GetMapping("/documents/{id}")
public Document getDocument(@PathVariable Long id) {
return documentRepository
.findByIdAndOwnerId(id, currentUser().getId())
.orElseThrow();
}
Wo immer möglich, sollten Sie Bezeichner gar nicht erst preisgeben und den Benutzer aus der Session ableiten. Halten Sie Bezeichner in mehrstufigen Abläufen in der Session, damit der Client sie zwischen den Schritten nicht manipulieren kann.
Zufällige Bezeichner helfen, aber nur als zusätzliche Verteidigungsschicht (Defense in Depth). Fügen Sie eine Spalte mit einem Zufallsstring hinzu und verwenden Sie diese in URLs, oder nutzen Sie UUIDs als Primärschlüssel. Verschlüsseln Sie IDs nicht, um sie zu verbergen; das sicher umzusetzen ist schwierig.
Was ins Code-Review gehört
Lesen Sie jeden Handler, der eine vom Client gelieferte ID entgegennimmt, und finden Sie die Stelle, an der die Berechtigungsprüfung stattfindet. Schauen Sie zuerst, ob die Abfrage gegen eine auf den Benutzer eingegrenzte Menge läuft oder ein globales find aufruft und erst danach prüft, falls überhaupt. Bestätigen Sie anschließend, dass die Prüfung in jeder Funktion existiert, die aus Client-Eingaben auf einen Datensatz zugreift, auch bei Änderung, Löschung und Export.
Achten Sie auf Handler, die lediglich die Benutzer-ID der Session (zum Beispiel aus dem JWT extrahiert, das Sie mit unserem JWT-Tool untersuchen können) mit dem ID-Parameter vergleichen. Laut OWASP deckt das nur eine kleine Teilmenge der Fälle ab. Die Autorisierung muss Benutzerrichtlinien und Hierarchien folgen. In einem mandantenfähigen System kann ein Admin in Mandant X eine Rechnung anfordern, die zu Mandant Y gehört. Der Benutzer ist authentifiziert, die Rolle ist Admin und die ID ist gültig, sodass eine reine Rollenprüfung bestehen kann, obwohl das Objekt im falschen Mandanten liegt.
Halten Sie bei der Triage zwei Fehlerklassen auseinander. Bei BOLA darf der Benutzer den Endpunkt konzeptionell aufrufen, und der Verstoß liegt in der manipulierten ID. Erreicht ein Benutzer einen Endpunkt, den er überhaupt nicht erreichen dürfte, handelt es sich um eine fehlerhafte Autorisierung auf Funktionsebene (Broken Function Level Authorization), die eine andere Behebung erfordert.
Womit Sie anfangen
- Erfassen Sie jeden Endpunkt, jedes Formular und jede GraphQL-Operation, die einen Bezeichner akzeptiert, einschließlich Dateinamen und versteckter Felder. Starten Sie mit Ihrer OpenAPI-Spezifikation oder Routing-Tabelle und ergänzen Sie, was nur im Proxy-Verlauf auftaucht, wenn Sie sich durch die Anwendung klicken.
- Führen Sie den oben beschriebenen Zwei-Benutzer-Durchlauf zuerst für die zehn sensibelsten Objekte durch, etwa Rechnungen, personenbezogene Daten und Admin-Aktionen, und dokumentieren Sie jede Antwort, die keine Ablehnung ist.
- Ersetzen Sie globale Abfragen durch auf den Benutzer eingegrenzte und nutzen Sie dafür den Autorisierungsmechanismus Ihres Frameworks.
- Nehmen Sie die parametrisierten Cross-User-Tests in Ihre Pipeline auf und lassen Sie einen Fehlschlag das Release blockieren.
- Ergänzen Sie die Review-Fragen in Ihrer Pull-Request-Vorlage. Für eine externe Prüfung dessen, was Ihre Tests übersehen haben, können Sie unseren Penetrationstest-Service beauftragen.
