← All posts

Wir haben unseren eigenen Auth-Server vor dem Launch auditiert. Die Bugs hatten eine Form.

Authagonal·September 3, 2026
securityauditsamloidcmfascimssrfwar-story

Bevor wir irgendjemanden gebeten haben, uns seine Logins anzuvertrauen, sind wir unseren eigenen Auth-Server so durchgegangen, wie es ein Angreifer täte: nicht, indem wir ihn ausgeführt haben, sondern indem wir ihn gelesen haben. Herausgekommen ist ein schriftliches Review der gesamten Oberfläche: sechzehn nummerierte Abschnitte, 261 Zeilen, von SAML über OIDC-Föderation, SCIM, die Admin-API und MFA bis zum Backup-Werkzeug, jeder Fund mit einem Schweregrad und einer Fundstelle im Code. Acht waren als zuerst zu beheben markiert. Diese acht haben wir an einem Abend behoben und den Rest der Liste eine halbe Stunde später. Zweieinhalb Wochen danach haben wir dieselbe Übung über die mandantenfähige Control Plane laufen lassen und fünf weitere gefunden, geschlossen am Abend, bevor wir in Produktion gegangen sind.

Hier ist die Zahl, die hängen geblieben ist. Die Testsuite meldete vor diesem Abend 367 von 367 bestanden und danach 367 von 367. Nichts wurde rot, weil nichts in der Suite auf irgendetwas davon gerichtet war. Tests belegen, woran man beim Testen gedacht hat, und niemand hatte daran gedacht zu testen, ob das Entfernen eines XML-Attributs, das die Signatur nicht abdeckt, eine wiedereingespielte Assertion auf einen freundlicheren Zweig leiten würde.

Liest man sie als acht unzusammenhängende Bugs, ist so eine Liste für alle anderen nutzlos. Liest man sie als Formen, sind es drei Gewohnheiten, wiederholt über Subsysteme hinweg, in Code, den Leute geschrieben haben, die die Spezifikationen gelesen hatten.

Die client_id, der Forwarded-Header, das InResponseTo und der E-Mail-Claim

Die erste Gewohnheit besteht darin, einen Wert, den der Aufrufer schreibt, als Tatsache über den Aufrufer zu behandeln. Die beiden, die das Review als kritisch markiert hat, waren jeweils eine einzige Eingabe: ein Query-Parameter und ein E-Mail-Claim.

Die MFA-Policy wurde aus der client_id abgeleitet, die aus der returnUrl im Login-Formular geparst wurde, und /connect/authorize hat nie erneut geprüft. Richtet man den Login auf einen Client mit deaktivierter MFA, kam die Session ohne zweiten Faktor heraus und ließ sich anschließend bestens bei dem Client einlösen, der einen verlangte. Dazu gibt es einen eigenen Artikel.

Limits pro IP schlüsselten auf die Remote-Adresse, und Forwarded-Headern wurde aus 0.0.0.0/0 heraus vertraut. Ein X-Forwarded-For-Wert pro Request machte jede Kontrolle pro IP zur unverbindlichen Empfehlung und schrieb als Bonus die IP-Spalte im Audit-Log um. Die Details stehen hier.

Der SAML Assertion Consumer wählte zwischen dem SP-initiierten Pfad (Request-ID verbrauchen, einmalig) und dem IdP-initiierten Pfad (einen Assertion-ID-Cache prüfen) allein danach, ob die Response ein InResponseTo-Attribut trug. Dieses Attribut sitzt am <Response>-Element, das nicht abgedeckt ist, wenn nur die Assertion signiert wird, und das ist der Normalfall. Entfernt man es, nahm eine abgefangene Response den Zweig, dessen Replay-Cache der ursprüngliche Login nie befüllt hatte, und jede Signatur verifizierte weiterhin. Warum die Signatur nie das Problem war.

Und wiederkehrende föderierte Nutzer wurden über die E-Mail in der Assertion aufgelöst, jede Verbindung konnte also die Adresse von jemand anderem behaupten und auf dessen Konto landen. Auch das ist aufgeschrieben.

Die Admin-API war eine Fabrik für ihr eigenes Credential

Die Admin-Autorisierung hing an genau einer Sache: an einem Token, dessen scope-Claim den Admin-Scope trug. Zwei Admin-Endpunkte setzten genau diesen Scope in ein frisches Token.

POST /api/v1/token, der Impersonation-Endpunkt, den die Support-Werkzeuge nutzen, zerlegte seinen scopes-Query-Parameter und kopierte das Ergebnis wortwörtlich in ein Access Token und ein Refresh Token, ohne Abgleich mit den registrierten AllowedScopes des Clients. Ein Admin-Credential, so kurzlebig es auch war, erzeugte damit ein langlebiges Admin-Refresh-Token für jeden Nutzer und jeden Client: ein Privileg, das die Rotation genau des Credentials überlebt, um das man sich Sorgen gemacht hat. Das Anlegen eines Clients wiederum band ein rohes Client-Objekt direkt aus dem Request-Body, ClientSecretHashes inklusive, sodass ein Admin einen client_credentials-Client registrieren konnte, der den Admin-Scope und einen Secret-Hash trug, den er bereits kannte. Auflisten und Abrufen gaben jeden gespeicherten Hash zurück.

Der Impersonation-Endpunkt verweigert jetzt den Admin-Scope und begrenzt den Rest auf die eigenen AllowedScopes des Clients, Anlegen und Aktualisieren binden ein DTO ohne Secret-Hash-Feld, und keine Response trägt einen Hash. Eine Berechtigung, die sich selbst ausstellen kann, ist keine Berechtigung.

Ein Bindestrich, eine besitzerlose Gruppe und eine E-Mail, die die Arbeit einer Identität macht

Die zweite Gewohnheit besteht darin, etwas, in dem ein Name steckt, als Stellvertreter für eine Identität durchgehen zu lassen.

Die E-Mail war der Join-Key für föderiertes Anmelden: global, bei vielen Providern vorhanden und genau das, was die behauptende Verbindung sagt. Die Lösung war, über das Paar aus Provider und Subject aufzulösen, das keine andere Verbindung erzeugen kann.

In der Control Plane war der Tenant-Slug der Name. Der Router, der einen Slug in ein Präfix für Storage-Tabellen übersetzt, entfernte Bindestriche, acme-corp und acmecorp ergaben also dasselbe Präfix, und das hieß dasselbe Tabellenset und denselben Signaturschlüssel in Vault. Zwei getrennt registrierbare Mandanten, die sich ein Tabellenset und einen Signaturschlüssel teilen. Registrieren Sie den zweiten, und Sie sehen die Nutzer des ersten. Die Lösung war keine klügere Präfix-Funktion. Sie bestand darin, Bindestriche in Slugs zu verbieten, damit die Abbildung injektiv ist, und das ist die längere Fassung wert.

SCIM war der Fund, der wehtat, weil die Hälfte davon richtig war. Jedes Lesen und Schreiben von Nutzern prüfte erneut, dass die Ressource vom aufrufenden Client provisioniert worden war. Gruppen, in der Datei nebenan, hatten überhaupt keinen Besitzer: Beim Anlegen wurde nie einer gestempelt, das Auflisten übergab null als Besitzerfilter und lieferte jede Gruppe in der Umgebung zurück, und Abrufen, Ersetzen, Patchen und Löschen akzeptierten jede beliebige ID. Eine Gruppen-ID zu kennen war die gesamte Autorisierung, und wo mehrere Provisioning-Clients sich eine Umgebung teilen, ist das client-übergreifendes Lesen und Schreiben von Gruppenmitgliedschaften, und darüber werden Rollen vergeben. Gruppen tragen jetzt den anlegenden Client, und jedes Lesen und Schreiben ist darauf begrenzt.

Die dritte Gewohnheit besteht darin, das Unumkehrbare zu tun, bevor die Prüfung läuft.

Das klarste Beispiel ist ein Feature, das Kunden kaufen: Ein Mandant registriert einen Webhook, der zur Authentifizierungszeit gefragt wird „diesen Login zulassen?“, und kann einstellen, dass die Antwort durchgesetzt wird. Der Hook lief auf jedem Pfad, der eine Session aufbaut, korrekt. Er lief nur nach SignInAsync. Eine Ablehnung warf also eine Exception, der Request kam als 500 zurück, und der Browser hielt bereits ein signiertes Session-Cookie. Jeder Client, der den Fehler ignorierte, war eingeloggt, blockiert oder nicht. Der Login-blockierende Webhook blockierte nicht.

Die Lösung ist eine Zeile, sechsmal verschoben: den Hook vor SignInAsync ausführen, bei Passwort, OIDC, SAML, MFA-Verifikation und MFA-Einrichtung, und vor dem Ausstellen von Tokens am Token-Endpunkt. Die Änderung in der Bibliothek und die in der Control Plane landeten zwei Minuten auseinander. Eine Ablehnung ist jetzt ein sauberes 403 ohne Set-Cookie, und ein End-to-End-Test stellt sicher, dass ein abgelehnter Login kein .AspNetCore.Cookies ausstellt.

Dieselbe Reihenfolge tauchte an drei leiseren Stellen auf. Authorization Codes wurden in zwei Operationen gelesen und dann gelöscht, zwei gleichzeitige Einlösungen desselben Codes konnten also beide das Lesen passieren und beide Tokens bekommen; der Grant Store bietet jetzt TryConsumeAsync, ein bedingtes Löschen, das nur dem Aufrufer true zurückgibt, der die Zeile tatsächlich entfernt hat. Der Restore hat Entitäten upsertet, bevor irgendetwas verifiziert wurde: Das Feld FileHashes war als beim Restore verifiziert dokumentiert, wurde aber nie befüllt und nie geprüft, und damit wird ein manipuliertes Backup zu einem Schreibprimitiv gegen die Produktion. Und der Stripe-Webhook handelte auf Events, bevor er festhielt, dass er sie gesehen hatte, eine At-least-once-Zustellung konnte also Plan und Status eines Mandanten flattern lassen oder Mahnungen erneut versenden. Alle drei prüfen jetzt zuerst.

Die URL beim Speichern zu validieren beweist nichts, wenn sie angewählt wird

Kunden geben uns URLs: Auth-Webhooks, Backup-Ziele, Retention-Callbacks. Eine abzulehnen, die im Moment des Speicherns auf eine private Adresse auflöst, ist der naheliegende Schutz, und für sich allein ist er beinahe wertlos, denn DNS ist kein Versprechen. Ein Name, der beim Validieren öffentlich auflöst, kann beim Verbinden auf 169.254.169.254 auflösen.

Die tragende Prüfung sitzt deshalb nicht im Settings-Handler. Sie ist ein Connect-Callback am HTTP-Handler selbst. Jeder ausgehende Aufruf an eine vom Kunden gelieferte URL löst den Host auf, verweigert Loopback, RFC1918, Unique-Local und den gesamten Link-Local-Bereich 169.254.0.0/16, in dem der Metadatendienst der Cloud wohnt, und öffnet den Socket dann zu genau dieser validierten Adresse statt zum Hostnamen, sodass keine zweite Auflösung übrig bleibt, die sich umbiegen ließe. Automatische Redirects sind aus, denn ein 302 ist eine weitere Gelegenheit, ein internes Ziel zu benennen. Die Prüfung beim Speichern bleibt als Fehlermeldung für den Kunden erhalten, und dafür war sie ohnehin immer nur gut.

Zwei Endpunkte und eine Backup-Datei, die nur das Netzwerk geschützt hat

/_internal/cluster/gossip war anonym gemappt, und seine Shared-Secret-Prüfung existierte als Kommentar statt als Code. /_internal/backchannel-logout war anonym gemappt, mit deaktiviertem Antiforgery auf dem öffentlichen Listener, nahm einen Body der Form {"SubjectId": "..."} entgegen und widerrief jeden Grant dieses Subjects. Keiner von beiden hatte irgendeinen Schutz im Prozess. Davor stand einzig die Ingress-Konfiguration, und angesichts des Forwarded-Header-Funds weiter oben war jede IP-basierte Verteidigung ohnehin fälschbar. Beide verlangen jetzt ein Shared Secret, das in konstanter Zeit verglichen wird, oder eine interne Quelladresse, wenn kein Secret konfiguriert ist.

Backups hatten denselben Charakter. Auf Hosts, die die lokale Schlüsselquelle nutzen, hält die Tabelle der Signaturschlüssel das private EC-Skalar, und das Backup serialisierte jede Spalte wortwörtlich in unverschlüsseltes, gzip-komprimiertes JSONL. Wer eine Backup-Datei lesen konnte, konnte Tokens für den Issuer fälschen, aus dem sie stammte. Diese Tabelle ist jetzt standardmäßig ausgeschlossen, mit einem ausdrücklichen Opt-in für verschlüsselte Ziele, und jede Datendatei wird per SHA-256 in das Manifest gehasht und verifiziert, bevor der Restore auch nur eine einzige Zeile schreibt.

Wo eine Prüfung läuft, macht den größten Teil ihres Werts aus

Nichts davon war eine fehlende Prüfung in dem Sinne, dass niemand daran gedacht hätte. MFA war implementiert und getestet. Der Webhook-Hook lief auf jedem Pfad. Die SSRF-Validierung existierte. SCIM-Nutzer waren bei jedem Verb korrekt eingegrenzt, in der Datei neben der, in der Gruppen es nicht waren. In fast jedem Fall war die Logik geschrieben, korrekt und an einer Stelle installiert, an der sie ihre Aufgabe nicht erfüllen konnte: auf einem Wert, den der Aufrufer geliefert hat, an einem Schritt, den der Aufrufer überspringen konnte, nach dem Zustand, den sie verhindern sollte, oder auf der Ressource nebenan.

Die nützliche Frage an jedem Endpunkt lautet deshalb nicht „validieren wir das“. Es sind drei andere. Was beweist dieser Request tatsächlich, im Unterschied zu dem, was er behauptet? Ist dieser Bezeichner etwas, das ein Aufrufer benennen kann, oder etwas, das nur der richtige Aufrufer besitzen kann? Und passiert irgendetwas Unumkehrbares, bevor die Antwort eintrifft? Ein schriftliches Review, Abschnitt für Abschnitt durchgearbeitet, mit einem Schweregrad an jedem Fund, ist vor allem ein Mittel, diese drei Fragen an dem Code vorbeizuzwingen, bei dem man sich sicher ist. Bei uns war man sich sicher. Es waren sechzehn Abschnitte Sicherheit, plus acht Dinge, die zu jemandes Incident geworden wären.

Nichts davon war für einen Kunden erreichbar, weil es noch keine Kunden gab. Das ist das Argument dafür, so etwas vor dem Launch zu machen statt nach der ersten Meldung: Dieselbe Liste, sechs Monate später geschrieben, ist eine Disclosure-Timeline statt der Arbeit eines Abends. Wenn Sie die aktuelle Fassung der Antworten wollen: Unsere Security-Seite legt dar, wie Mandanten isoliert werden, was im Ruhezustand verschlüsselt ist und was ein Backup Ihrer Daten tatsächlich enthält.