W czerwcu skierowaliśmy Penetrify na naszą własną aplikację, tak jak robimy to przed większym wydaniem. Skan wrócił z problemem w naszym logowaniu przez Cognito, a kiedy pociągnąłem za ten wątek, wyszło z tego coś, czego nie czytałem z przyjemnością: dashboard Penetrify trzymał tokeny access i refresh z Cognito w localStorage, w postaci jawnej, czytelne dla dowolnego JavaScriptu działającego na stronie.
Nie usiedliśmy i tak nie zdecydowaliśmy. To domyślne zachowanie biblioteki uwierzytelniania, której użyliśmy, i nigdy go nie zakwestionowaliśmy. Właśnie o tym warto napisać.
Czym dokładnie było to znalezisko
Nasz dashboard to aplikacja single-page. Po logowaniu dostawała tokeny z AWS Cognito, a SDK umieszczało je w pamięci przeglądarki, bo tam je umieszczają SDK, kiedy w sesji nie bierze udziału serwer. Przeczytać je mógł każdy skrypt z dostępem do strony.
To znaczy: jeden błąd cross-site scripting gdziekolwiek w aplikacji, jedno złośliwe rozszerzenie przeglądarki albo jedna skompromitowana zależność npm, i atakujący wychodzi z oboma tokenami. Token access wpuszcza go na konto. Token refresh go tam utrzymuje, bo pozostaje ważny długo po tym, jak sesja wygląda na zamkniętą, dopóki coś go wyraźnie nie unieważni.
Dla naszych użytkowników w zasięgu były dane rozliczeniowe, wyniki skanów ich aplikacji i dane o wypłatach afiliacyjnych. Na ile potrafimy to ocenić, nikt tego nie wykorzystał. Ale nie o to chodzi. Było osiągalne i to była nasza własna domyślna wartość do naprawy.
Dlaczego szyfrowanie localStorage nie jest rozwiązaniem
Pierwszy odruch to zaszyfrować tokeny przed zapisem. Nie pomaga. Klucz deszyfrujący musi żyć w tym samym JavaScripcie, który atakujący już wykonuje. Dodałeś krok, nie granicę.
Drugi odruch to przenieść tokeny do ciasteczka po stronie klienta. To też nie pomaga. Ciasteczko ustawione przez JavaScript jest przez JavaScript czytelne. Właściwość, która ma znaczenie, to HttpOnly, a ustawić ją może tylko serwer, nagłówkiem Set-Cookie. Jeśli twoja poprawka działa w przeglądarce, to nie jest poprawka.
Co wdrożyliśmy zamiast tego
Całą sesję przenieśliśmy do ciasteczek ustawianych przez serwer, wzorcem token handler wewnątrz backendu, który już mieliśmy:
- Backend FastAPI wykonuje wywołania do Cognito i zwraca sesję jako ciasteczka z
HttpOnly,SecureiSameSite. Przeglądarka nigdy nie widzi tokenu. - Middleware uwierzytelniania czyta token access z ciasteczka, a nie z nagłówka
Authorization. - Ponieważ ciasteczka wysyłają się automatycznie, żądania modyfikujące niosą teraz nagłówek z tokenem CSRF, który backend weryfikuje.
- Wylogowanie wykonuje global sign-out po stronie Cognito, więc token, który kiedyś wyciekł, umiera razem z sesją, zamiast ją przeżyć.
- SDK uwierzytelniania zniknęło z przeglądarki całkowicie.
Nie potrzebowaliśmy do tego nowej usługi. Nasza statyczna aplikacja i nasze API są już na tym samym originie: CloudFront serwuje aplikację i kieruje /api/* do API Gateway. Ciasteczko first-party jest więc wysyłane przy każdym wywołaniu API bez dodatkowej infrastruktury.
Najpierw przyjrzeliśmy się ścieżce rekomendowanej przez dostawcę, co oznaczałoby przejście na hostowany interfejs logowania, i odrzuciliśmy ją. Wyrzuciłaby nasze własne ekrany logowania, MFA i resetu hasła, żeby rozwiązać problem, który potrafimy rozwiązać w istniejącym backendzie.
Ile to kosztowało
Około tygodnia pracy i żadnej widocznej zmiany dla użytkowników. To uczciwe podsumowanie większości pracy nad bezpieczeństwem i dokładnie dlatego takie rzeczy w startupie odkłada się w nieskończoność. Nic na roadmapie się nie rusza. Żaden klient o to nie prosi. Zmienia się tylko to, co jeden wstrzyknięty skrypt może zrobić twoim użytkownikom.
Jeden praktyczny szczegół, który uczynił wdrożenie bezpiecznym: podczas rolloutu backend nadal przyjmował starą ścieżkę przez nagłówek, więc cofnięcie deployu frontendu było rollbackiem w jednym kroku, gdyby cokolwiek poszło nie tak.
Sprawdź własną aplikację. Zajmie to około 30 sekund.
Otwórz swoją aplikację, zaloguj się, potem otwórz DevTools:
- Zakładka Application, Local Storage. Szukaj długich wartości z dwiema kropkami. Ten kształt to JWT.
- W tym samym panelu sprawdź Session Storage. Ten sam problem, krótsze życie.
- W konsoli wpisz
document.cookie. Cokolwiek się tam pojawi, jest z definicji czytelne dla JavaScriptu, więc ciasteczko sesji, które widzisz w tym wyniku, cię nie chroni.
Jeśli znajdziesz token, to jest znalezisko. Nie teoretyczne i nie takie, o którym powiedziałby ci zielony audyt zależności albo czysty przebieg analizy statycznej.
Dlaczego to publikujemy
Dwa powody, a drugi dotyczy naszego własnego produktu.
Pierwszy: domyślne ustawienia to decyzje, które podjął za ciebie ktoś inny. Przychodzą z biblioteką, są wygodne i prawie nigdy nie są modelem zagrożeń. Nasze było rozsądnym domyślnym ustawieniem dla tutorialu i złym dla aplikacji trzymającej dane płatnicze.
Drugi: skan jest wskazówką, nie wyrokiem. Nasz oznaczył przepływ logowania i dowiódł, że jest osiągalny. Nie podał nam zdania „twoja biblioteka uwierzytelniania ma domyślnie localStorage i właśnie to trzeba zmienić". Droga od znaleziska do poprawki oznaczała otwarcie własnej ścieżki uwierzytelniania i przeczytanie jej. Dokładnie tak oczekujemy, że będziesz używać naszych raportów: znalezisko mówi, gdzie patrzeć, i pokazuje żądanie, które to dowodzi, a ty decydujesz, co jest właściwą poprawką w twojej architekturze.
Więc: uruchom skan na własnej aplikacji, a potem przeczytaj kod, na który wskazuje. Zrobiliśmy jedno i drugie, i żadna z tych połówek nie wystarczyłaby sama.
Jeśli chcesz spojrzenie na swoją aplikację z zewnątrz od razu, nasz darmowy security check zajmuje około 60 sekund i nie wymaga rejestracji. Jeśli chcesz pełny raport z dowodami i poprawkami, pierwszy skan kosztuje $29 i zalicza się na poczet planu. A jeśli wolisz tę darmową rzecz, którą właśnie opisaliśmy, otwórz DevTools i zajrzyj do swojego local storage. Ta nie kosztuje nic.
