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
graphqlorgqlpath segment. Finding them sends no request. - The conventional paths
/graphqland/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.__typenameis the mutation root type (Mutation,mutation_rootin Hasura,RootMutationTypein 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,errorsandextensionskeys. - 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 ID | security/graphql-get-mutations |
| Category | Security |
| Scope | Site-wide |
| Severity | error |
| Weight | 7/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.