Im Juni haben wir Penetrify auf unsere eigene Anwendung gerichtet, so wie wir es vor einem größeren Release tun. Der Scan kam mit einem Problem in unserem Cognito-Login-Flow zurück, und als ich an diesem Faden gezogen habe, wurde daraus etwas, das ich nicht gern gelesen habe: das Penetrify-Dashboard hielt Access- und Refresh-Tokens von Cognito im localStorage, im Klartext, lesbar für jedes JavaScript auf der Seite.
Wir haben uns nicht hingesetzt und das so entschieden. Es ist der Default der Auth-Bibliothek, die wir verwendet haben, und wir haben ihn nie hinterfragt. Genau das ist der Teil, über den es sich zu schreiben lohnt.
Was der Fund tatsächlich war
Unser Dashboard ist eine Single-Page-App. Beim Login bekam sie Tokens von AWS Cognito, und das SDK legte sie im Browser-Storage ab, denn dort legen SDKs sie ab, wenn kein Server an der Session beteiligt ist. Jedes Skript mit Zugriff auf die Seite konnte sie lesen.
Das heißt: ein Cross-Site-Scripting-Bug irgendwo in der App, eine bösartige Browser-Erweiterung oder eine kompromittierte npm-Abhängigkeit, und ein Angreifer geht mit beiden Tokens raus. Das Access-Token bringt ihn in den Account. Das Refresh-Token hält ihn dort, denn es bleibt lange gültig, nachdem die Session geschlossen aussieht, bis etwas es ausdrücklich widerruft.
Für unsere Nutzer lagen Rechnungsdaten, Scan-Ergebnisse ihrer Anwendungen und Affiliate-Auszahlungsdaten in Reichweite. Soweit wir es beurteilen können, hat es niemand ausgenutzt. Darum geht es aber nicht. Es war erreichbar, und es war unser eigener Default, den wir zu reparieren hatten.
Warum das Verschlüsseln von localStorage nicht die Lösung ist
Der erste Reflex ist, die Tokens vor dem Speichern zu verschlüsseln. Das hilft nicht. Der Schlüssel muss in demselben JavaScript liegen, das der Angreifer schon ausführt. Du hast einen Schritt hinzugefügt, keine Grenze.
Der zweite Reflex ist, die Tokens clientseitig in ein Cookie zu verschieben. Das hilft ebenfalls nicht. Ein von JavaScript gesetztes Cookie ist von JavaScript lesbar. Die Eigenschaft, auf die es ankommt, ist HttpOnly, und setzen kann sie nur ein Server, über einen Set-Cookie-Header. Wenn dein Fix im Browser läuft, ist es kein Fix.
Was wir stattdessen ausgeliefert haben
Wir haben die gesamte Session auf servergesetzte Cookies umgestellt, mit einem Token-Handler-Muster in dem Backend, das wir schon hatten:
- Das FastAPI-Backend führt die Cognito-Aufrufe aus und gibt die Session als Cookies mit
HttpOnly,SecureundSameSitezurück. Der Browser sieht nie ein Token. - Die Auth-Middleware liest das Access-Token aus dem Cookie statt aus einem
Authorization-Header. - Weil Cookies automatisch mitgesendet werden, tragen verändernde Requests jetzt einen CSRF-Token-Header, den das Backend prüft.
- Das Logout führt einen Global Sign-out auf Cognito-Seite aus, damit ein früher geleaktes Token mit der Session stirbt statt sie zu überleben.
- Das Auth-SDK ist komplett aus dem Browser verschwunden.
Wir brauchten dafür keinen neuen Service. Unsere statische App und unsere API sind bereits Same-Origin: CloudFront liefert die App aus und routet /api/* an API Gateway. Ein First-Party-Cookie wird deshalb bei jedem API-Call mitgesendet, ohne zusätzliche Infrastruktur.
Wir haben zuerst den vom Anbieter empfohlenen Weg geprüft, der ein gehostetes Login-UI bedeutet hätte, und ihn verworfen. Er hätte unsere eigenen Screens für Login, MFA und Passwort-Reset weggeworfen, um ein Problem zu lösen, das wir im bestehenden Backend lösen können.
Was es gekostet hat
Etwa eine Woche Arbeit, und keine sichtbare Änderung für Nutzer. Das ist die ehrliche Zusammenfassung der meisten Sicherheitsarbeit, und genau deshalb wird so etwas in einem Startup endlos verschoben. Auf der Roadmap bewegt sich nichts. Kein Kunde fragt danach. Was sich ändert, ist nur, was ein einzelnes injiziertes Skript deinen Nutzern antun kann.
Ein praktisches Detail, das das Ausrollen sicher gemacht hat: während des Rollouts akzeptierte das Backend weiterhin den alten Header-Pfad, ein Zurückrollen des Frontend-Deploys war also ein Rollback in einem Schritt, falls etwas schiefgeht.
Prüf deine eigene App. Dauert etwa 30 Sekunden.
Öffne deine App, melde dich an, dann öffne die DevTools:
- Tab Application, Local Storage. Such nach langen Werten mit zwei Punkten darin. Diese Form ist ein JWT.
- Prüf im gleichen Panel Session Storage. Gleiches Problem, kürzere Lebensdauer.
- Tipp in der Konsole
document.cookie. Alles, was dort auftaucht, ist per Definition für JavaScript lesbar, ein Session-Cookie, das du in dieser Ausgabe siehst, schützt dich also nicht.
Wenn du ein Token findest, ist das ein Finding. Kein theoretisches, und keines, von dem dir ein grüner Dependency-Audit oder ein sauberer Lauf der statischen Analyse erzählt hätte.
Warum wir das veröffentlichen
Zwei Gründe, und der zweite betrifft unser eigenes Produkt.
Erstens: Defaults sind Entscheidungen, die jemand anderes für dich getroffen hat. Sie kommen mit einer Bibliothek, sie sind bequem, und sie sind fast nie ein Bedrohungsmodell. Unserer war ein vernünftiger Default für ein Tutorial und ein schlechter für eine App, die Zahlungsdaten hält.
Zweitens: Ein Scan ist ein Hinweis, kein Urteil. Unserer hat den Login-Flow markiert und belegt, dass er erreichbar war. Er hat uns nicht den Satz geliefert "deine Auth-Bibliothek nutzt per Default localStorage, und genau das musst du ändern". Vom Hinweis zum Fix hieß: den eigenen Auth-Pfad öffnen und lesen. Genau so erwarten wir, dass du unsere Reports nutzt: das Finding sagt dir, wo du hinschauen musst, und zeigt den Request als Beweis, und dann entscheidest du, was in deiner Architektur der richtige Fix ist.
Also: lass den Scan auf deine eigene App laufen und lies dann den Code, auf den er zeigt. Wir haben beides getan, und keine der beiden Hälften hätte allein gereicht.
Wenn du den Blick von außen auf deine App sofort willst, unser kostenloser Security-Check dauert etwa 60 Sekunden und braucht keine Registrierung. Wenn du den vollen Report mit Beweis und Fixes willst, kostet der erste Scan $29 und wird auf deinen Plan angerechnet. Und wenn du lieber die kostenlose Sache machst, die wir gerade beschrieben haben: öffne die DevTools und schau in deinen eigenen Local Storage. Die kostet nichts.
