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 a GET request in a RESTful API.
  • mutation should be used for operations which may create, alter, or delete data - equivalent to POST, PUT, and DELETE 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.

Bypassing GET Mutation Restrictions

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 a mutation 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-graphqldoes3.

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.