Kiedyś myślałem, że napisanie własnej warstwy uwierzytelniania to powód do dumy. Jeśli rozumiesz JWT i potrafisz zahaszować hasło, to co może pójść nie tak? Skonfigurowałem backend w Node, wystawiałem tokeny i uważałem sprawę za zamkniętą. Kod działał. Testy przeszły. Potem zacząłem czytać o tym, jak w rzeczywistości wyglądają ataki, i poczułem, że grunt usuwa mi się spod nóg. Każdy rozdział o wstrzykiwaniu (injection), enumeracji i wyciekach kanałem bocznym (side-channel leaks) sprawiał, że wracałem do edytora z narastającym niepokojem. Mój system uwierzytelniania nie miał tylko luk; miał cztery szeroko otwarte drzwi, które sam zainstalowałem. Oto co odkryłem i co dokładnie zmieniłem.

SQL Injection poprzez konkatenację ciągów znaków

Pierwszy błąd był najstarszym trikiem świata. Przyjmowałem dane od użytkownika i wrzucałem je bezpośrednio do ciągów SQL. W mojej trasie logowania pobierałem e-mail z ciała żądania i konkatenowałem go w zapytaniu typu SELECT * FROM users WHERE email = '${email}'. Wydawało mi się to nieszkodliwe, bo kontrolowałem frontend. To niebezpieczne założenie. Atakujący nie potrzebuje Twojego frontendu. Pojedyncze, zmanipulowane ciało żądania POST mogło zmienić to sprawdzenie logowania w wyciek danych lub wyczyszczenie bazy danych. Wysłanie ładunku (payload) takiego jak ' OR '1'='1 lub co gorsza, zapytania typu stacked query, które usuwa tabele, sprawi – jeśli ciąg zostanie wykonany surowo – że Twoje dane przepadną. Nie dałem bazie danych żadnej możliwości odróżnienia mojego kodu od danych atakującego.

Rozwiązaniem nie była lepsza walidacja danych wejściowych ani ręczne ucieczkowanie (escaping) ciągów. Prawdziwym rozwiązaniem były zapytania parametryzowane. Przeszedłem na node-postgres i zacząłem używać placeholderów, takich jak $1. Zapytanie staje się szablonem: SELECT * FROM users WHERE email = $1. Sterownik wysyła SQL i wartości oddzielnymi kanałami. Baza danych traktuje dane wejściowe ściśle jako dane, niezależnie od tego, jakie znaki zawierają. Ta jedna zmiana zamyka całą klasę ataków typu injection. Jest to prostsze w odczycie, łatwiejsze w utrzymaniu i zdejmuje z Ciebie ciężar bycia czarodziejem wyrażeń regularnych za każdym razem, gdy piszesz klauzulę WHERE.

Enumeracja adresów e-mail poprzez komunikaty błędów

Mój drugi błąd wyglądał jak dobre UX. Gdy użytkownik wpisał błędny e-mail, zwracałem User not found. Gdy e-mail był poprawny, ale hasło błędne, zwracałem Incorrect password. Wydawało się to pomocne. Było to jednak również narzędziem do rozpoznania dla atakujących. Skrypty do enumeracji mogą zasypywać Twój endpoint logowania tysiącami adresów e-mail. Jeśli ciało odpowiedzi lub kod statusu zmienia się w zależności od tego, czy konto istnieje, skrypt może zbudować zweryfikowaną listę Twoich użytkowników. Ta lista staje się fundamentem dla ataków typu credential stuffing, ukierunkowanego phishingu i dalszych prób brute-force.

Musiałem zaakceptować, że przyjazność dla użytkownika musi czasem ustąpić miejsca bezpieczeństwu. Zmieniłem każdą ścieżkę nieudanego logowania tak, aby zwracała dokładnie ten sam ciąg: Invalid credentials. Żadnych podpowiedzi. Żadnej logiki rozgałęzionej w odpowiedzi błędu. Niezależnie od tego, czy e-mail nie istnieje, hasło jest błędne, czy konto jest zablokowane, tekst pozostaje identyczny. Dotyczy to również procesów rejestracji i resetowania hasła; nie ujawniaj, czy dany adres znajduje się już w Twoim systemie. Jedna ogólna wiadomość eliminuje wyciek informacji, na którym polegają atakujący.

Ataki czasowe (Timing Attacks) w porównywaniu haseł

Trzeci błąd był niewidoczny. Byłem