Awesome GraphQL CSRF and SSRF
Authored by Lachlan Davidson
Background
GraphQL is an incredibly powerful technology gaining a lot of traction these days; however, the number of footguns is impressive, especially if you roll your own implementation. It's no wonder that Apollo is so popular!
However, like any new technology, a lot of the gotchas aren't widely known. In this post I aim to go over a handful of specific mistakes made when validating GraphQL requests, and why they make for devastating CSRF opportunities and SSRF impacts.
GraphQL Operations
Let's start with some of the basic building blocks of a GraphQL request. The GraphQL specification defines three types of operations: query
, mutation
and subscription
.
query
should be used for retrieving any data without any side affects, semantically akin to aGET
request in a RESTful API.mutation
should be used for operations which may create, alter, or delete data - equivalent toPOST
,PUT
, andDELETE
requests in RESTful APIs.subscription
- used to create pub/sub sockets.
In almost all cases, it is imperative that query
operations never alter data, for many of the same reasons that GET
requests should never alter data. However, this is only merely suggested, but not required. Quoting the official documentation1:
In REST, any request might end up causing some side-effects on the server, but by convention it's suggested that one doesn't use GET requests to modify data. GraphQL is similar - technically any query could be implemented to cause a data write. However, it's useful to establish a convention that any operations that cause writes should be sent explicitly via a mutation.
It is up to the developer to actually define the behaviour of any GraphQL query
or mutation
; however, due to the succinct naming, developers tend to be pretty good at using a query
and mutation
operations appropriately.
HTTP Verbs
GraphQL APIs typically allow both GET
and POST
requests. POST
requests tend to look something like this:
POST /api/graphql HTTP/1.1
Host: localhost
Content-Type: application/json
{
"query":"<graphql stuff here>"
}
Whereas GET requests tend to look like this:
GET /api/graphql?query=%3Cgraphql+stuff+here%3E HTTP/1.1
Host: localhost
The official reccomendations for GraphQL HTTP APIs2 also states the following:
In addition to the above, we recommend supporting two additional cases:
If the "query" query string parameter is present [in a POST request] (as in the GET example above), it should be parsed and handled in the same way as the HTTP GET case.
(...omitted...)
This is an important point which I'll touch on later.
Introducing GraphQL CSRF
So what's the big deal? Well, I'm sure a many of us have seen contrived examples (or the occasional pathetic real-world instance) of GET
request CSRF. But it seems so unrealistic that something as silly as http://site/account/changeEmail?new_email=hacker@pwnd
would ever exist in the real world - it's just so obviously bad. However, a few slipups in GraphQL implementations can let us do exactly this, and so, so much worse.
A Simple Example
We'll start with the obvious - using query
operations in place of mutation
operations.
Think back to what a GraphQL GET
request looks like. If you chose to implement your changeEmail
function as a GraphQL query
, you could simply send a victim to https://site/api/graphql?query=...
where the query expands to:
query {
changeEmail(email: "hacker@pwnd")
}
This is exactly why a query
should never be used to create, modify or delete data. It can lead to silly-easy CSRF. This gets especially bad if things such as SameSite cookies aren't configured properly. Imagine what would happen if you embedded an image in a malicious site, linking to a vulnerable GraphQL endpoint <img src=https://site/api/graphql?query=... style="display:none;">
.
Luckily though, as I touched on before, developers are pretty good at appropriately using query
and mutation
operations correctly, so this example is still a little contrived. However, there are some more assumptions we can start to poke at.
GET
Mutation Restrictions
Bypassing So let's assume the developers have done a good job with semantically using query
and mutation
. What else is there to attack?
Well, remeber when I said GET
requests should not allow mutation
operations? This is often just seen as a minor detail, and implementations to prevent this are often incredibly poor. Let's break them.
I discovered a GET
mutation bypass vulnerability in the GraphQL API of (...redacted...).
Their restriction effectively boiled down to the following (I've simplified it down a bit):
// Don't allow mutations from GET requests for security reasons
if (request is a GET request) {
stripped_query = trim_whitespace(query)
if (stripped_query starts with "mutation") {
throw new Error("Only POST requests allow mutations")
}
}
So what's wrong here? Well, there are plenty of valid GraphQL mutation requests which don't start with the string "mutation"
. The easiest one I could think of is simply starting it with a comment.
# hello, i'm a comment!
mutation {
doSomeBadThings()
}
But it turned out, all that was required was to start the request with %0a
. This allowed me to neatly wrap things up into a link like so: https://site/graphql?query=%0amutation+%7b+doSomeBadThings()+%7d
.
The impact here ended up being quite significant, as a user's normal session cookie was all the GraphQL API needed for authorization, and the functionality offered by the API was very wide. All a victim had to do was click a URL which directed them to the vunlerable endpoint and actions could be performed on their account left, right, and centre.
I guess HttpOnly cookies might not be what they're all cracked up to be - storing tokens in localStorage and sending them in headers might not be so bad after all, eh?
Here's some other ways to try and bypass mutation restrictions:
- Is case-jumbling blocked? e.g.
mUtaTiOn
- this shouldn't be allowed as per the GraphQL spec, but who cares what the spec says? - What if you put both a
query
and amutation
in the same request? Do both go through? Does it validate both?
GET
Paramaters in POST
Requests
Going back to earlier, I mentioned that it is officially reccomended that GraphQL APIs which recieve POST
requests should not only handle queries in their body, but also queries within the URL paramaters. The most critical part of this is "it should be parsed and handled in the same way as the HTTP GET case."
. But almost no implementations do this correctly. In many popular GraphQL server implementations, including the official express-graphql
implementation, this is handled poorly.
Let's look at what express-graphql
does3.
const urlData = new URLSearchParams(request.url.split('?')[1]);
const bodyData = await parseBody(request);
// GraphQL Query string.
let query = urlData.get('query') ?? (bodyData.query as string | null);
First off, it parses the GET
paramaters into urlData
for later use. Then, it does a little bit of work to get the request body. Next, if there is a query specified in the GET
paramaters, it will use that, else, use the POST
body query.
Then, if we jump back a few lines4 we can see where the HTTP verb validation actually happens.
// Only query operations are allowed on GET requests.
if (request.method === 'GET') {
// Determine if this GET request will perform a non-query.
const operationAST = getOperationAST(documentAST, operationName);
if (operationAST && operationAST.operation !== 'query') {
// (...omitted...)
// Otherwise, report a 405: Method Not Allowed error.
throw httpError(
405,
`Can only perform a ${operationAST.operation} operation from a POST request.`,
{ headers: { Allow: 'POST' } },
);
}
}
Wait a second? Does this correctly fullfil the specification of "it should be parsed and handled in the same way as the HTTP GET case."
? No! When making a POST
request with a ?query=
parameter specified, this does not correctly prevent mutation
operations, because the request method is still GET
. Let's see an example.
POST /api/graphql?query=mutation+%7B+doSomeBadThings()+%7D HTTP/1.1
Host: localhost
It doesn't even matter what's here in the body. Because the query paramater is specified, express-graphql ignores this. But this is a POST request, we can trust it to make mutations, right?
This will be accepted by express-graphql
and doSomeBadThings()
will be done.
So what's the big deal? Well, we just made our lives 100 times easier - not only for different types of CSRF, but for exploiting SSRF vulnerabilities as well.
This eliminates many defence-in-depth measures; now POST
and form-based CSRF attacks only need to be made with a certain URL - no worries about content types or carefully crafting a body.
Sometimes when you find a SSRF vulnerabilitiy, it can only make POST
requests, and you might not be able to control what's in the body - only the URL. But express-graphql
and many other implementations couldn't care less what's in the body when you supply a GET
paramater! If you can point SSRF towards an internal vulnerable GraphQL server, you can start to cause some real havoc.
Why this is so Impactful
Usually when we deal with CSRF vulnerabilities, we're just dealing with one form that ignores a CSRF token, or some other scenario where one slip up doesn't break the whole system, and if it does, complex attacks require a large number of sequential request to be executed. However, with GraphQL APIs, huge sections of functionality are often protected behind just a handful of measures. But most importantly, GraphQL APIs can sequentially execute a large number of operations in a single request.
Consider the following:
mutation {
createCart()
addToCart(item: "SKU-000")
setPaymentMethod(value: "saved_credit_card")
startCheckout()
setAddress(value: {
lineOne: "123 Hacker Street"
city: "Auckland"
country: "New Zealand"
})
placeOrder()
}
If you can pack mutations like this into a single CSRF payload, you have yourself a very flexible payload which executes quickly in just one request.
Conclusions
GraphQL is a great technology, but it must be handled with care. It is absolutely vital that whatever implementation you use, it must be incredibly well tested and hardened. The GraphQL specification is intentionally open-ended, and the reccomendations have nuances which must be carefully considered.
There is room for some absolutely devastating CSRF exploits and SSRF tricks here. Keep an eye out.
- https://graphql.org/learn/queries/#mutations↩
- https://graphql.org/learn/serving-over-http/↩
- https://github.com/graphql/express-graphql/blob/0fe65108ec53a22b0db752c4018ca3aa6745b689/src/index.ts#L479↩
- https://github.com/graphql/express-graphql/blob/0fe65108ec53a22b0db752c4018ca3aa6745b689/src/index.ts#L308↩