An einem einzigen Tag habe ich sechs Sicherheitslücken in einer Transkriptions-API geschlossen, die seit Monaten lief. Keine davon hat ein Test gefunden. Die Tests waren vorher grün und nachher auch. Was sich geändert hat, war nicht das Werkzeug: Ich habe mich hingesetzt und meinen eigenen Code gelesen mit der Frage, wie ich ihn kaputt machen würde, wenn ich wollte, statt zu fragen, ob er tut, was ich mir gedacht hatte.
Dieser Unterschied klingt nach Haltung. Er ist methodisch, und man merkt es an der Sorte Fehler, die dabei herauskommt.
Ein Test prüft den Weg, den du dir vorgestellt hast
Ein Test wird vom Anwendungsfall her geschrieben: vernünftige Daten, vernünftige Ausgabe. Ein Angreifer schickt keine vernünftigen Daten. Er schickt das Leere, die Null, das Negative, das Riesige. Diese Werte stehen selten in der Suite, denn wenn du den Test schreibst, kommen sie dir nicht in den Sinn, und wenn sie dir in den Sinn kommen, hast du den Bug schon gesehen.
Der klarste Fall war eine leere ACL. Die Retrieval-Suche bekam eine Liste von Identifikatoren, auf die der Nutzer Zugriff hatte, und filterte den Korpus damit. Kam die Liste leer an, interpretierte der Code das als "es gibt keinen Filter anzuwenden" statt als "dieser Nutzer hat auf nichts Zugriff". Sie gab den gesamten Korpus zurück.
Das schlug offen fehl: Der Standardwert einer fehlenden Berechtigung war, alles zu erlauben. Kein Happy-Path-Test berührt das jemals, denn im Happy Path bringt die Liste immer etwas mit.
Seitdem prüfe ich alle Autorisierungsfilter mit derselben Frage: Was macht das hier, wenn die Sammlung leer ist? Nicht "Was macht es, wenn der Nutzer keine Berechtigung hat?", das ist meist gut abgedeckt, sondern was es mit dem Leeren macht.
Die Kategorien wiederholen sich mehr, als man denkt
Die Funde gruppieren sich fast immer in dieselben Familien.
- Berechtigungen, die nicht dort erzwungen werden, wo sie müssen. Ein Export-Endpoint, der Daten herausließ, ohne die Autorität für Strukturverwaltung zu verlangen. Ein kaskadierendes Löschen einer Domain, das die Berechtigung einmal am Anfang prüfte, statt die Hard-Delete-Autorität über jedes betroffene Element zu prüfen. Die Kaskade ist der klassische Ort: Du autorisierst die Operation und dann macht die innere Schleife Dinge, die niemand autorisiert hat.
- Grenzen, die es nicht gibt. Die Admin-Listen reichten das ankommende
limitdirekt an die Query weiter, ohne Obergrenze. Ein großer Wert aus einem Skript materialisierte die ganze Tabelle im Speicher des Workers. Der Fix passt in eine Zeile,Math.min(Math.max(limit, 1), 1000), aber er fehlte, weil in der UI daslimitimmer zehn war. - Zustand, der einmal geprüft wird und nie wieder.
authenticateApiKeyvalidierte den Hash, das Ablaufdatum und dasisActive-Flag des Schlüssels. Aber die Schlüsselzeile überlebt den Zustand des Kontos ihres Besitzers: Wenn der Abrechnungseintrag bei einer Kündigung aufgeräumt wird, setzt niemand dieses Flag, und der Schlüssel authentifiziert weiter. Jetzt revalidiert die Funktion, dass der Besitzer noch einen Abrechnungseintrag hat, an einer Stelle, damit alle Endpoints, die per Schlüssel authentifizieren, das erben, statt sich jeder einzeln daran erinnern zu müssen. - Fremde Inhalte, die gerendert werden. Hier steckt der, der mich am meisten überrascht hat.
CSS ist auch eine Injection
Der Markdown-Sanitizer erlaubte die Attribute style und class frei in div, span und svg. Die Kette geht von raw über sanitize zu harden, und keine Stufe schaut ins CSS hinein: Die Sanitizer-Bibliothek behandelt style als opaken String, und der Hardener schreibt nur URLs in a und img um. Der Style kam unverändert im finalen Render an.
Ich habe überlegt, einen CSS-Parser mit Property-Whitelist zu schreiben. Habe ich nicht gemacht: Ich habe geprüft, dass keine legitime View style braucht, und die beiden Attribute aus der erlaubten Liste genommen. Ein eigener CSS-Filter ist neue Oberfläche, die man pflegen muss, um einen Fall abzudecken, den es nicht gab.
Das Wichtige an dem Fund ist nicht der Fix, sondern die Art des Vektors. Man braucht kein JavaScript, um einen anderen Nutzer anzugreifen: Mit CSS kannst du einen Button verdecken, einen Text über einen anderen schieben oder ändern, was eine Nachricht scheinbar sagt. Und jede View, die fremde Inhalte rendert, nicht nur eigene, ist ein Cross-User-Vektor.
Secrets in Logs und Links, die zu lange leben
Zwei Funde, die keine Logikfehler sind, sondern Lebenszyklusfehler.
Der erste: Wenn die API-Key-Authentifizierung wegen eines korrupten oder falsch formatierten Schlüssels fehlschlug, schrieb der Catch den rohen Schlüssel in den Log-Kontext, und der Logger serialisierte diesen Kontext mit JSON.stringify, ohne irgendetwas zu schwärzen. Ein gültiger Schlüssel, der an den falschen Endpoint geschickt wurde, landete im Klartext im Log. Die Redaktion, die ich eingebaut habe, greift von zwei Seiten an, über den Feldnamen (/rawkey|token|authorization|password/i) und über die Form des Werts (das Präfix dk_live_ / dk_test_), und lässt die ersten acht Zeichen sichtbar, gefolgt von ***REDACTED*** — dasselbe Format, mit dem die UI den Schlüssel wieder versteckt, nachdem sie ihn einmal gezeigt hat.
Der zweite: Die signierten S3-URLs für den Audio-Player wurden mit einem expiresIn von sieben Tagen erzeugt. Sieben Tage sind eine Ewigkeit für einen Link, der nur überleben muss, bis ein <audio> geladen hat. Jeder mit dem Link — weitergeleitet, im Verlauf, von einem Firmenproxy gecacht — behielt das ganze Fenster über Zugriff auf das Originalaudio, und die Transkription zu löschen widerrief gar nichts. Ich habe die Ablaufzeit auf 600 Sekunden gesenkt, mit Erneuerung vom Client nach 480, und beim Löschen der Transkription ein DeleteObjectCommand gegen das Objekt ergänzt, denn bis dahin löschte die Kaskade nur die Zeile.
Die Kosten für die Nutzererfahrung waren null bis auf einen Ablauf: den Audio-Link per Mail an einen Kollegen weiterzuleiten. Dieser Ablauf war nie eine unterstützte Funktion und hatte keinerlei Zugriffskontrolle, und ich finde es richtig, dass er nicht mehr funktioniert.
Jetzt programmiere ich es, statt mich daran zu erinnern
Das Problem mit Audits ist, dass sie davon abhängen, dass dir eines Tages danach ist. Meins entstand, weil ich Designentscheidungen dokumentierte und beim Nachlesen Dinge querlagen.
Jeder Fund wird zu einer kurzen Datei in docs/security/ mit dem Vektor und der Begründung, warum der Fix dieser ist und kein anderer, und daraus entsteht die Liste, mit der ich den Rest durchgehe. Als ich die Übung an dem Boilerplate wiederholte, mit dem ich Projekte starte, kamen neununddreißig Punkte zwischen Sicherheit, SEO und Architektur heraus, und die meisten waren Varianten derselben Handvoll Fragen: Was passiert mit der leeren Liste, wer erzwingt die Berechtigung in der inneren Schleife, wo ist die Obergrenze dieses Limits, wie lange lebt dieser Link.
Deinen Code zu lesen, um zu bestätigen, dass er funktioniert, und ihn zu lesen, um zu sehen, wie man ihn bricht, sind zwei verschiedene Lektüren. Die zweite passiert nicht nebenbei, während du die erste machst.
