Jak testować GraphQL - introspekcja, zagnieżdżanie, batching
W skrócie: GraphQL jest jedynym stylem API, który ma w OWASP Web Security Testing Guide v4.2 własny podrozdział (WSTG-APIT-01). Powód jest konkretny: jeden adres, jedna metoda i klient, który sam decyduje, co serwer policzy. Znikają przez to trzy rzeczy, na których opiera się test REST - lista punktów końcowych, rozróżnienie metod i proporcja między liczbą żądań a kosztem ich obsługi.
Praktyczna konsekwencja: skaner chodzący po adresach nie ma tu po czym chodzić. Powierzchnię ataku wyznacza schemat, a nie routing.
W poprzednim wpisie o metodyce testowania REST API napisaliśmy, że REST nie dostał w WSTG osobnego rozdziału, bo nie wnosi nic poza samym HTTP. GraphQL dostał, i ten wpis jest o tym, co dokładnie w nim wnosi. Pozostałe jedenaście rozdziałów WSTG obowiązuje nadal - uwierzytelnianie, sesje, kryptografia i logika biznesowa testuje się tak samo. Poniżej jest to, co dochodzi na wierzchu.
Co zmienia się względem REST
W REST adres jest jednostką testu: GET /orders/{id} to jedno miejsce, w którym sprawdza się autoryzację. W GraphQL cały ruch idzie zwykle na POST /graphql, a to, co się naprawdę wykonuje, siedzi w treści żądania. Jedno zapytanie potrafi dotknąć kilkunastu typów danych, przejść przez kilka poziomów powiązań i uruchomić kilkadziesiąt funkcji rozwiązujących pola (resolwerów). Z punktu widzenia logów serwera to jedno żądanie 200 OK.
| W REST | W GraphQL |
|---|---|
| Powierzchnia ataku = lista adresów | Powierzchnia ataku = schemat: typy, pola, argumenty, mutacje |
| Autoryzacja pilnowana przy punkcie końcowym | Autoryzacja musi być pilnowana przy każdym polu, bo do pola prowadzi wiele dróg |
| Metoda HTTP rozdziela czytanie od zapisu | Rozdziela to typ operacji w treści: query, mutation, subscription |
| Jedno żądanie to z grubsza jedna operacja | Jedno żądanie to dowolnie wiele operacji - przez aliasy albo tablicę operacji |
| Limit liczby żądań ogranicza nadużycie | Limit liczby żądań nie ogranicza niczego, dopóki nie liczy kosztu zapytania |
Etap 1: rozpoznanie - czy to GraphQL i na czym stoi
Pierwsze pytanie brzmi, gdzie ten adres w ogóle jest. Konwencja mówi /graphql, ale w praktyce bywa /api/graphql, /v1/graphql, /query albo adres wzięty z konfiguracji biblioteki, której nikt nie zmieniał. Obok głównego adresu sprawdzamy, czy nie działa interfejs do ręcznego pisania zapytań - GraphiQL, Apollo Sandbox, Playground. To nie jest podatność sama w sobie, ale wystawiony na produkcji daje atakującemu komplet dokumentacji i gotowe narzędzie do jej przeglądania.
Drugie pytanie to, która implementacja serwera obsługuje ten adres. Ma to znaczenie, bo domyślne zachowania różnią się między silnikami i od nich zależy, co uda się na dalszych etapach: czy introspekcja jest włączona domyślnie, czy serwer podpowiada nazwy pól przy literówce, czy przyjmuje tablicę operacji w jednym żądaniu, czy obsługuje zapytania przez GET. Silnik rozpoznaje się po kształcie komunikatów o błędach i po reakcji na nietypowe zapytania; robi to automatycznie graphw00f.
Etap 2: introspekcja, czyli schemat na życzenie
GraphQL ma wbudowany mechanizm opisu samego siebie. Zapytanie o pole __schema zwraca komplet: wszystkie typy, wszystkie pola każdego typu, argumenty, mutacje, wartości wyliczeniowe. To jest zaprojektowana funkcja języka, nie błąd implementacji - narzędzia deweloperskie i podpowiadanie składni opierają się właśnie na niej. Problem polega na tym, że na produkcji zostaje włączona razem z całą resztą.
Dla testu włączona introspekcja to bardzo dobra wiadomość, bo zastępuje cały pierwszy etap testu REST. Nie trzeba rekonstruować powierzchni ataku z przechwyconego ruchu i zgadywania po wzorcu - serwer sam ją wypisuje, kompletną i aktualną. Zaczynamy wtedy od czytania schematu i szukania w nim pól, których w interfejsie nigdy nie widać: isAdmin, passwordResetToken, internalNotes, mutacji obsługujących panel administracyjny.
Z praktyki
Wyłączenie introspekcji nie zamyka tematu i nie należy go traktować jako zabezpieczenia. Większość serwerów przy nieistniejącej nazwie pola odpowiada podpowiedzią w stylu „Did you mean email?". To wystarcza, żeby odtworzyć schemat metodą prób - robi to narzędzie clairvoyance. Wyłączona introspekcja jest więc utrudnieniem, a nie kontrolą dostępu, i w raporcie opisujemy ją właśnie tak. Realną granicą jest to, czy pole zwraca dane osobie, która nie powinna ich zobaczyć.
Etap 3: autoryzacja na poziomie pola, nie adresu
To jest sedno testu GraphQL, dokładnie tak jak Broken Object Level Authorization było sednem testu REST - opisaliśmy je w wpisie o OWASP API Security Top 10. Różnica jest w tym, gdzie tej kontroli szukać. W REST sprawdzenie uprawnień siedzi przed obsługą adresu i albo jest, albo go nie ma. W GraphQL do jednego pola prowadzi wiele dróg, a kontrola musi stać przy polu, nie przy wejściu.
Najczęstszy wzorzec błędu wygląda tak: zapytanie user(id: 42) { email } jest poprawnie zabezpieczone, bo ktoś pamiętał o nim przy pisaniu resolwera. Ale to samo pole da się osiągnąć okrężnie, przechodząc przez powiązania:
- Przez zagnieżdżenie -
{ post(id: 7) { comments { author { email } } } }. Poleemailjest to samo, droga inna, a resolwer typuAuthorzagnieżdżonego pod komentarzem bywa pisany przez kogoś innego i w innym tygodniu. - Przez pole zwracające listę - jeżeli istnieje
user(id:), warto sprawdzić, czy nie istnieje teżusersbez argumentów, zwracające komplet jednym zapytaniem. - Przez mutację - mutacja zwraca zmieniony obiekt. Jeżeli zwraca go bez sprawdzenia uprawnień do odczytu, to sama staje się drogą do danych, których odpowiednie zapytanie by nie wydało.
- Przez pole wyliczane - pola agregujące (liczniki, sumy, statystyki) często omijają warstwę uprawnień, bo „nie zwracają danych, tylko liczbę". Liczba też jest daną i potrafi odpowiedzieć na pytanie zadane z zewnątrz.
Metoda pracy jest ta sama co w REST i równie nudna: co najmniej dwa konta tej samej roli z własnymi, rozróżnialnymi danymi, po jednym koncie na każdą wyższą rolę, i każda droga do wrażliwego pola sprawdzona trzy razy - jako właściciel, jako obce konto tej samej roli i bez tokenu. Z introspekcją w ręku wiadomo przynajmniej, jakie drogi istnieją, więc macierz da się wypełnić systematycznie, a nie po omacku.
Etap 4: koszt zapytania, zagnieżdżanie i aliasy
Schemat GraphQL bywa cykliczny, bo powiązania działają w obie strony: autor ma wpisy, wpis ma autora. Cykl da się przejść dowolnie wiele razy w jednym zapytaniu - { user { posts { author { posts { author { ... } } } } } } - i każdy kolejny poziom mnoży liczbę wywołań resolwerów oraz zapytań do bazy. Klient wysyła kilkaset bajtów, serwer wykonuje pracę rosnącą wykładniczo z głębokością.
Drugi mechanizm to aliasy. GraphQL pozwala wywołać to samo pole wiele razy w jednym zapytaniu pod różnymi nazwami: { a: raport(id:1) b: raport(id:2) c: raport(id:3) }. Setka aliasów na kosztownym polu to jedno żądanie HTTP i setka wykonań tego, co za tym polem stoi. Jeżeli pole dotyka usługi rozliczanej za każde wywołanie, rachunek rośnie w tym samym tempie co obciążenie.
W teście sprawdzamy, czy serwer ma którekolwiek z czterech ograniczeń: limit głębokości zapytania, analizę kosztu z budżetem na żądanie, wymuszoną paginację na polach zwracających listy i limit czasu wykonania. Brak wszystkich czterech przy publicznie dostępnym adresie to znalezisko samo w sobie, nawet jeżeli nie dowozimy go do zatrzymania usługi - a celowo tego nie robimy, bo test ma pokazać ryzyko, nie zrealizować je na żywym systemie.
Etap 5: batching, czyli sto prób logowania w jednym żądaniu
Ten etap jest wart osobnego miejsca, bo podważa zabezpieczenie, które po stronie klienta wygląda na działające. Liczniki prób logowania, limity żądań, blokada konta po pięciu nieudanych próbach, limit kodów jednorazowych - wszystko to zwykle liczy żądania HTTP. GraphQL pozwala zmieścić w jednym żądaniu dowolnie wiele operacji, na dwa sposoby: przez tablicę operacji w treści, jeżeli serwer to obsługuje, albo przez aliasy, które obsługuje zawsze, bo to element języka.
Mutacja logowania wywołana pod stu aliasami daje sto prób uwierzytelnienia przy jednym zwiększeniu licznika żądań. Ten sam wzorzec działa na wszystkim, co opiera się na ograniczeniu liczby prób: kody jednorazowe, tokeny resetu hasła, kody rabatowe, zaproszenia. Dlatego sprawdzamy, na jakiej warstwie stoi limit - jeżeli przy serwerze proxy albo przy regule liczącej żądania, to w GraphQL nie liczy tego, co trzeba. Licznik musi siedzieć przy operacji, nie przy połączeniu. Do szybkiego sprawdzenia, czy serwer przyjmuje operacje zbiorczo, nadaje się graphql-cop.
Etap 6: klasyka pod nową składnią
Resztę listy zamykają podatności, które nie są specyficzne dla GraphQL, ale inaczej się do nich dochodzi.
- Wstrzyknięcia - argumenty pól trafiają do zapytań do bazy tak samo jak parametry w REST. Nowe jest to, gdzie ich szukać: w argumentach filtrów, sortowania i wyszukiwania, które w GraphQL bywają rozbudowanymi obiektami wejściowymi przekazywanymi do warstwy danych niemal bez zmian.
- CSRF - jeżeli serwer przyjmuje zapytania metodą
GETalbo akceptujePOSTz nagłówkiemContent-Type: text/plainlubapplication/x-www-form-urlencoded, żądanie da się wysłać z obcej strony bez zapytania wstępnego (preflight). Przy uwierzytelnianiu opartym na ciasteczkach to gotowy scenariusz CSRF, więc sprawdzamy, czy mutacje da się wywołać inaczej niż przezapplication/json. - Komunikaty o błędach - domyślne ustawienia bibliotek zwracają w odpowiedzi ślad stosu, nazwy plików i wersje zależności. Dochodzi do tego to, co opisaliśmy wyżej: podpowiedzi nazw pól ujawniają schemat niezależnie od introspekcji.
- Obsługa odpowiedzi - dane z GraphQL wracają do przeglądarki tak samo jak każde inne i podlegają tym samym zasadom kodowania wyjścia, o których pisaliśmy przy niezaufanych danych w odpowiedzi i w przeglądzie OWASP Top 10.
- Przesyłanie plików - jeżeli schemat obsługuje wysyłkę plików, idzie ona osobnym kanałem (
multipart) i trzeba ją przetestować jak każdy upload: typ, rozmiar, nazwa, miejsce zapisu.
Co trzeba przygotować przed startem
- Schemat - plik
SDLalbo wynik introspekcji z środowiska testowego. Jeżeli test ma być prowadzony bez schematu, warto to ustalić świadomie, bo zmienia zakres: część czasu idzie wtedy na odtwarzanie schematu zamiast na sprawdzanie uprawnień. - Co najmniej dwa konta na każdą rolę z osobnymi danymi. Bez tego etap trzeci nie istnieje, a to on decyduje o wartości całego testu.
- Decyzja co do limitów i ograniczeń kosztu - czy mają zostać włączone, czy podniesione dla adresu testującego.
- Ustalenie, czy wolno testować koszt zapytań na tym środowisku i do jakiej skali. Zapytanie sprawdzające brak limitu głębokości potrafi obciążyć serwer bardziej niż cała reszta testu razem.
- Kontakt awaryjny - jak w każdym teście, szerzej we wpisie jak przygotować firmę do pentestu.
Co z tego da się zautomatyzować
Więcej niż w REST, bo schemat jest danymi, które narzędzie potrafi przeczytać. Rozpoznanie silnika, zrzut introspekcji, wygenerowanie z niego zapytań na każde pole, sprawdzenie obsługi operacji zbiorczych, limitu głębokości, trybu GET i komunikatów o błędach - to wszystko robią narzędzia i w testach penetracyjnych API używamy ich właśnie na tych etapach, żeby czas został na resztę.
Etap trzeci zostaje człowiekowi, z tego samego powodu co w REST i w testach aplikacji webowych: narzędzie wie, że pole email zwróciło wartość, ale nie wie, czyj to adres i czy konto, które go właśnie dostało, miało prawo go zobaczyć. Odpowiedź 200 OK z cudzymi danymi w treści jest dla skanera sukcesem. Dla testera jest znaleziskiem.
Macie API w GraphQL?
Sprawdzamy autoryzację pole po polu, z dwóch kont o różnych uprawnieniach, i liczymy koszt zapytań, które Wasz serwer przyjmie bez mrugnięcia. Napiszcie - odpiszemy w 24 h.
Zapytaj o wycenę