Bezpieczeństwo: Cross-Site Request Forgery
Dziś omówimy trzeci co do popularności atak po Cross-Site Scripting (XSS) i SQL Injection (SQLi), mianowicie Cross-Site Request Forgery. Oprócz omówienia samego mechanizmu przedstawię też metody ochrony przed nim oraz zaproponuję przykładową implementację dla naszej aplikacji fiszkowej.
Wideo towarzyszące
Mechanizm ataków CSRF
Cross-Site Request Forgery (CSRF) jest atakiem powiązanym z tematem autoryzacji i autentykacji użytkowników, ponieważ strony podatne na taki atak pozwalają na wykonywanie operacji w imieniu użytkownika bez jego wiedzy i zgody. Można to więc uznać za niepoprawną autentykację ze strony serwera. Jeżeli chcesz poczytać więcej na temat tych ataków, polecam cheat sheet OWASP na ten temat albo stronę MDN wprowadzającą do tego zagadnienia.
Podatność na CSRF
Aby atak CSRF był możliwy, wystarczy że aplikacja spełnia dwa warunki:
- Identyfikuje użytkowników na podstawie cookies sesji
- Przyjmuje dane z formularzy w postaci żądań POST i realizuje na ich podstawie akcje użytkownika
Aplikacja fiszkowa, którą rozwijaliśmy do tej pory oczywiście spełnia oba warunki, więc jest na ten atak podatna.
Działanie CSRF
Wyobraźmy sobie złośliwą stronę zupełnie niezwiązaną z naszą aplikacją, która w swoim kodzie HTML będzie miała ukryty formularz, którego atrybut action będzie wskazywał na adres naszej aplikacji. Wysłanie takiego formularza poskutkuje tym, że przeglądarka wyśle do naszej strony żądanie z powiązanymi danymi oraz cookies przypisanymi do naszej strony. Wysłanie ukrytego formularza może się odbyć zupełnie bez wiedzy użytkownika - wystarczy krótki kawałek kodu JavaScript, który wywoła wysłanie formularza.
Z punktu widzenia serwera naszej aplikacji, przychodzące żądanie wygląda na zupełnie poprawne żądanie od zalogowanego użytkownika. Jeżeli tylko podczas odwiedzania złośliwej strony użytkownik jest zalogowany w naszym serwisie, nie mamy powodów by podważyć przychodzące żądanie. W przypadku aplikacji z fiszkami nie ma to większego znaczenia, ale gdyby chodziło o stronę banku internetowego albo portal społecznościowy, to bez dodatkowych zabezpieczeń użytkownik i jego bliscy mogą być narażeni na wymierne straty.
Teoretycznie nawet formularz HTML nie jest konieczny, bo z poziomu JavaScript jest możliwe przesłanie danych przy pomocy żądania Xml Http Request, (XHR, często nazywane też AJAX). Przeglądarki były niestety bardzo ufnymi stworzeniami i wysłanie danych z formularza do strony niepowiązanej z obecnie odwiedzaną witryną traktowały jak zupełnie normalne zachowanie.
Aby oddać sprawiedliwość deweloperom przeglądarek trzeba przyznać, że bezpieczeństwo tych aplikacji znacznie wzrosło w ostatnich latach. Namówienie przeglądarki do tego aby wysłała cookies do innej strony niż obecnie odwiedzana w sposób niejawny dla użytkownika wcale nie jest dziś takie proste. Trzeba spełnić szereg warunków żeby to się udało i jest mała szansa, że będą one spełnione zupełnym przypadkiem. Co nie zwalnia nas z zabezpieczania naszych aplikacji.
Zapobieganie atakom CSRF
Aby uniemożliwić taki atak, wystarczy że do formularzy na naszej stronie dodamy tzw. CSRF token. Taki token to po prostu losowa liczba, która jest zapamiętana po stronie serwera, i którą porównamy z wartością przesłaną w formularzu. Złośliwa strona nie ma dostępu do tokenu, użytkownik otrzymuje taki token tylko i wyłącznie w wyniku wejścia na naszą stronę i pobrania formularza.
Jeżeli zapaliły Wam się teraz lampki ostrzegawcze, to bardzo dobrze. Skoro złośliwa strona może wysłać dane do naszej aplikacji używając XHR, to co stoi na przeszkodzie, żeby złośliwy JavaScript pobrał najpierw w imieniu użytkownika formularz z wypełnionym tokenem CSRF i odesłał do serwera przeczytaną wartość? Tu na szczęście jesteśmy chronieni, ponieważ API przeglądarki do pobierania danych z zewnętrznych stron wymaga aby serwer w odpowiedzi na żądanie z innej witryny potwierdził nagłówkiem HTTP Access-Control-Allow-Orgin, że aktualnie odwiedzana strona może pobierać dane ze odpytywanego serwisu. W przeciwnym wypadku przeglądarka nie udostępni danych odpowiedzi do zlecającego żądanie kodu.
Ustaliliśmy już, że serwer musi zapamiętać token CSRF, ale gdzie go przechowywać i jak długo powinien on być przechowywany?
Z jednej strony token może być powiązany z sesją użytkownika i być ważny tak długo, jak istnieje sesja. Dla krótko żyjących sesji jest to adekwatne rozwiązanie, natomiast jeżeli sesje w serwisie raczej nie wygasają, to wzrasta ryzyko, że tokeny CSRF wyciekną.
Alternatywą z drugiej strony spektrum jest generowanie nowego tokenu za każdym razem kiedy użytkownik pobierze formularz z takim tokenem, co niweluje zagrożenie związane z wyciekiem danych. Z drugiej strony generowanie nowego tokenu za każdym razem może powodować, że nawigowanie po stronie na kilku zakładkach albo korzystanie cofania się w przeglądarce będzie powodowało błędy walidacji CSRF.
Implementacja zabezpieczenia przeciwko CSRF
Pierwszym krokiem do dodania zabezpieczenia przeciwko CSRF jest wygenerowanie losowego tokenu i zapisanie go gdzieś w bazie danych. W tym momencie proponuję powiązanie tokenu z sesją użytkownika i nieodświeżanie go, póki sesja istnieje. Jest to możliwie najprostsza implementacja tego zabezpieczenia, ale na potrzeby naszej aplikacji wydaje się w zupełności wystarczająca.
| |
Pierwsze trzy podświetlone zmiany polegają na dodaniu kolumny csrf_token do tabeli z sesjami użytkowników i uwzględnieniu jej w przygotowanych wyrażeniach SQL.
W linii 35 generujemy losowy ciąg bajtów, który następnie zapisujemy go w kodowaniu base64. Tak przygotowany string zapisujemy w bazie danych, a także udostępniamy reszcie aplikacji poprzez obiekt sesji dostępny w zmiennej res.locals.session.
Kodowanie base64 jest często używane w programowaniu webowym, ponieważ pozwala na gęste upakowanie dowolnych danych binarnych w postaci znaków drukowalnych. Jeżeli jesteś ciekawy, jak to kodowanie działa, zazwyczaj dobrym punktem startowym jest odpowiednia strona an Wikipedii.
Kolejny etap implementacji składa się z dwóch ściśle powiązanych rzeczy:
- Dodania tokenu CSRF do wszystkich formularzy HTML jako ukryte pole
- Weryfikacji po stronie handlera, że otrzymany token CSRF zgadza się z tym w obiekcie sesji
Na przykładzie strony służącej do dodawania nowych zestawów fiszek, zmiana w formularzu wygląda następująco:
| |
A przykładowa walidacja tokenu może wyglądać w ten sposób.
| |
Teraz musimy po prostu dodać tokeny do pozostałych formularzy w naszej aplikacji, a następnie dodać walidację we wszystkich handlerach obsługujących te formularze.
Jeżeli chcesz, możesz prześledzić wszystkie zmiany w folderze poświęconym obecnej lekcji w repozytorium z kodem do naszych lekcji.
Testowanie wprowadzonych zabezpieczeń
W celu sprawdzenia, czy faktycznie nasze zabezpieczenie działa, przeprowadzimy najpierw symulowany atak na naszą aplikację w wersji sprzed wprowadzenia zabezpieczenia. Przygotujemy złośliwą witrynę, która w tle spróbuje wykonać działania na naszej stronie w imieniu zalogowanego obecnie użytkownika. Oprócz tego musimy zastanowić się, jak możemy przeprowadzić wszystkie wymagane testy lokalnie, bez potrzeby uruchamiania naszej aplikacji na zewnętrznym serwerze.
Lokalne domeny testowe
Cookies w przeglądarce są powiązane z domenami - dotychczas uruchamialiśmy naszą aplikację używając nazwy localhost i pod taką “domeną” przeglądarka zapisywała cookies naszej aplikacji. Jeżeli obok naszej aplikacji uruchomimy np. na innym porcie aplikację złośliwą, to otworzenie jej w przeglądarce używając nazwy localhost spowoduje wysłanie do niej wszystkich cookies utworzonych przez aplikację testowaną, a dodatkowo przeglądarka słusznie może dopuścić pełny przepływ danych między aplikacjami, bo przecież ewidentnie znajdują się na tej samej maszynie. Jeżeli chcemy przeprowadzić wiarygodną symulację ataku na naszą stronę, to musimy oddzielić od siebie w jakiś sposób aplikację złośliwą od atakowanej.
Zamiana domen na adresy IP odbywa się zazwyczaj z wykorzystaniem serwerów DNS, ale większość systemów operacyjnych pozwala też na lokalne definiowane domen w obrębie maszyny. W przypadku systemów Linux/Unix pozwala na to plik /etc/hosts, a na Windowsie podobny plik znajdziesz na dysku w lokalizacji “C:\Windows\System32\Drivers\etc\hosts”. Są to proste pliki, które zawierają adresy IP i przypisane im nazwy. W ten sposób, tworząc w plikach hosts odpowiednie wpisy, możemy rozdzielić nasze dwie aplikacje mimo działania na jednej maszynie:
##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting. Do not change this entry.
##
127.0.0.1 localhost
255.255.255.255 broadcasthost
::1 localhost
127.0.0.1 fiszki.localhost
127.0.0.1 evilquiz.localhostZłośliwa aplikacja
W celu ukrycia złośliwego działania drugiej aplikacji, posłużymy się przykładowym quizem znalezionym w Internecie, a następnie dopiszemy do niego kawałek kodu, który spróbuje wykonać na aplikacji fiszkowej operacje w imieniu użytkownika.
Aby przeprowadzić atak wystarczy, że w kodzie HTML strony zawrzemy taki formularz:
<form
id="theForm"
action="http://fiszki.localhost:8000/new_cardset"
method="POST"
style="display: none"
>
<input
type="text"
name="name"
id="cardset_name"
value="You have been PWNED"
/>
</form>A następnie gdzieś w kodzie JavaScript obsługującym akcję użytkownika zamieścimy taką linijkę kodu:
document.getElementById("theForm").submit();W efekcie przeglądarka prześle dane formularza i znajdziemy się na naszej aplikacji fiszkowej na stronie nowo dodanego zestawu kart. Nie jest to bardzo szkodliwa akcja, ale mimo wszystko nasza aplikacja nie powinna ślepo ufać żądaniom przychodzącym spoza niej.
Kod złośliwego quizu znajdziesz w folderze poświęconym obecnej lekcji w repozytorium z kodem do naszych lekcji.
Spróbuj zalogować się do aplikacji fiszkowej, a następnie używając złośliwej aplikacji quizu przeprowadzić nasz atak. W wersji po wprowadzeniu zabezpieczenia aplikacja fiszkowa powinna zwrócić błąd walidacji CSRF, blokując próbę wykonania akcji w imieniu zalogowanego użytkownika.