squirrelscan
GitHub

GraphQL mutations over GET: why they open the door to CSRF

A server that runs a mutation sent as a GET lets any site fire it with your visitors' cookies. squirrel sends a no-op mutation that cannot write and checks the answer.

Why a mutation over GET is a problem

A GET is a simple cross-site request. An image tag, a link prefetch or a plain link on any other website sends it to your server with the visitor’s cookies, and no CORS preflight stops it. If your GraphQL server executes a mutation that arrives as a GET, every mutation is one URL away from a cross-site request forgery (CSRF): another site can change a password, place an order or delete data as the signed-in visitor.

It also breaks the rule that GET is safe and idempotent. Browsers, crawlers, link previews and caches send and repeat GET requests freely, so a state-changing GET can run when nobody meant it to.

The GraphQL-over-HTTP spec says a server must reject a mutation over GET with 405 Method Not Allowed.

What squirrel checks

This is a probing rule. It sends requests beyond the crawl, so it runs only when probing intensity is active or aggressive. On a passive run it sends nothing and records one skipped check.

Which endpoints are probed

The same endpoints as security/graphql-introspection, chosen by the same shared code:

  • GraphQL endpoints the audit found in your pages and scripts: a URL with a graphql or gql path segment. Finding them sends no request.
  • The conventional paths /graphql and /api/graphql.

Every endpoint must have the same origin as the audited URL. An endpoint whose path names a state-changing action, such as logout, delete, update or reset, is never probed, and neither is a call site that declares PUT, PATCH or DELETE. Below aggressive, a path that robots.txt disallows is skipped. At most five endpoints are probed.

What is sent

One request per endpoint, and only this one:

GET /graphql?query=mutation{__typename}

mutation{__typename} is a no-op. It selects only __typename, which the GraphQL executor answers without running any of your resolvers, and it names no field of your schema, so nothing can be written. The rule never sends a schema-specific mutation and never sends a POST, at any probing level. The request carries no cookies and does not follow redirects, and at most 64 KB of the answer is read, counted after decompression; a compressed answer is decompressed no further than that.

Results

The rule emits checks named graphql-get-mutations:

  • Skipped when probing is passive. Message: Probing is passive, so no GraphQL endpoint was probed. Run with --probe active to check whether mutations run over GET.
  • Skipped, with reason probe-budget, when the shared probe budget ran out before the first request, or partway through with no finding on the endpoints it reached. A run cut short is never reported as a pass, because the endpoints it did not reach were not checked.
  • Skipped, with reason probe-errors, when every probe failed with a network error or timeout, so a failed run never reads as clean.
  • Fail, one check per endpoint, when the answer is a 2xx GraphQL response whose data.__typename is the mutation root type (Mutation, mutation_root in Hasura, RootMutationType in Absinthe and similar). Message: https://example.com/graphql executes GraphQL mutations sent over GET: any site can trigger a state-changing operation with your visitors' cookies (CSRF), and GET is no longer safe and idempotent. Reject non-query operations over GET and require POST with a non-simple Content-Type or a CSRF token.
  • Pass when no endpoint executed the mutation. Message: No GraphQL endpoint executed a mutation sent over GET (2 endpoint(s) probed, 1 answered as GraphQL).

What is not flagged

  • A 405, or any other non-2xx answer.
  • An error such as Can only perform a mutation operation from a POST request, an introspection error, or a schema with no mutation type.
  • An answer of Query, which means the operation ran as a query, not as a mutation.
  • An HTML page, a catch-all route or a JSON body that is not GraphQL-shaped. The status alone never decides it: the answer must be a JSON object with only data, errors and extensions keys.
  • An answer larger than 64 KB, which is not read.

Limits

The rule proves that the server executes a mutation sent as a GET. It does not check how your API authenticates: the CSRF risk is real when it trusts cookies, and smaller when every request needs an Authorization header that a cross-site request cannot set. Either way, a GET should not change state.

How to fix it

Reject every operation other than a query when the request is a GET, with a 405. Accept mutations only over POST, and require a non-simple Content-Type such as application/json, which a cross-site form or image cannot send without a CORS preflight, or a CSRF token.

Apollo Server, graphql-http and express-graphql already refuse a mutation over GET, so a finding usually points at a custom HTTP handler that passes GET query strings straight to the executor. Check the operation type before you execute it.

Rule IDsecurity/graphql-get-mutations
CategorySecurity
ScopeSite-wide
Severityerror
Weight7/10

Enable / disable

Disable this rule

[rules]
disable = ["security/graphql-get-mutations"]

Disable all Security rules

[rules]
disable = ["security/*"]

Enable only this rule

[rules]
enable = ["security/graphql-get-mutations"]

enable replaces the default ["*"], so this is the only rule selected. Leave disable out: a rule that matches any disable pattern is skipped even when enable names it. Only an enabled setting under a rule’s own [rule_options] table overrides both lists.

Type to search…

↑↓ navigate↵ openesc close