GraphQL Scanner
GraphQL puts the whole API behind one URL, which means the questions to ask are also few. The scanner finds that URL in the endpoint inventory, then sends three small POST requests: ask for the schema, ask for a deeply nested query, ask for two queries at once. Each answer is judged by a string in the response body.
Finding the endpoint
The scanner does not crawl or guess. It reads the inventory built by endpoint discovery and keeps every entry whose lowercased URL path contains /graphql, /api/graphql or /gql. Everything else is ignored, and if nothing matches, no request is sent at all.
1The filter looks at the path only. /graphql, /api/graphql and /gql/… pass; /api/Users and /rest/v1/tasks do not. The entry's HTTP method is irrelevant because the scanner will POST to it regardless.
2Every kept endpoint gets the same three probes, strictly in order, each a JSON POST with a 10 s timeout. A non-2xx status ends that probe with no finding. So does a network error, which is only logged at debug level.
3Between endpoints the scanner checks its time budget so a large inventory cannot push it into the hard cancel. If the login crawl produced a session, its headers go on every probe, so a GraphQL endpoint that is only reachable behind login is tested too.
Three probes, three rules
Each probe is one request. What differs is what the response body must contain before a finding is raised.
1Three rows, three string tests. Severity is fixed per probe: the schema leak and the missing depth limit are Medium, batching is Low.
2Introspection. A standard schema query. If a 2xx body contains __schema, __type or "types", the full list of types, fields, queries and mutations is public: a complete map of the API, including admin mutations and deprecated fields.
3Depth. A query nested ten levels deep. Without a depth limit, { user { friends { friends { … } } } } style queries cost exponentially more at each level, which is a cheap denial of service. The rule asks whether the response looks like the server did not refuse; section 03 shows how that is decided.
4Batch. Two __typename queries in one array. A body starting with [ and containing __typename means the server executed both, so one HTTP request can carry a thousand queries and slip under a per-request rate limit.
The introspection rule is a substring test on the raw body. A 2xx response whose error message merely quotes the word __schema would match too, and a server that blocks introspection with a non-2xx status is correctly left alone. The scanner never parses the JSON, so confidence reflects the check (0.90) rather than a validated schema.
Reading the depth answer
The depth probe is the one place where the rule needs care. The nested field is called a, which will not exist in a real schema, so a server that validates queries will usually answer with an error whatever its depth policy is. The decision therefore rests on three possible bodies.
1One query, three kinds of answer. The same two-clause rule sorts them: flag when there is no error text, or when the word data is present.
2Body A. No error anywhere in a 2xx body. The server ran the nested query without complaint, which is exactly what a missing depth limit looks like.
3Body B. An error that does not contain data, such as a depth-limit refusal. This is the guard: a server that enforces a limit says so, and the scanner stays quiet.
4Body C. The second clause lets a body through when it contains both an error and the word data, for example a "data":null next to a validation error. The rule counts that as accepted. Treat a Medium depth finding as a prompt to check the server's depth setting by hand.
Why it matters
GraphQL gives clients flexible query power, which is also its security risk. Introspection returns the entire schema, including internal types, admin mutations and deprecated fields with sensitive data. Deeply nested queries cause exponential database joins. Batching a thousand queries into one POST bypasses per-request rate limits. Once an attacker knows the schema, they craft queries to extract the data.
GitLab, 2019. GraphQL introspection was enabled by default, which let researchers enumerate private project information and user data. GitLab subsequently restricted introspection in production.
Vulnerable vs fixed
// BAD: playground and introspection on in production, no limits
const yoga = createYoga({
schema,
graphiql: true,
// no depth limit, no rate limiter, introspection answers anyone
})// GOOD: disable introspection in production
const yoga = createYoga({
schema,
graphiql: false, // disable playground
maskedErrors: true,
plugins: [
useDisableIntrospection(), // block __schema queries
useDepthLimit({ maxDepth: 10 }),
useRateLimiter({ max: 100, window: "1m" })
]
})With a depth limit in place the second probe comes back as body B above, and with introspection disabled the first probe no longer sees __schema. Note that a batch is still accepted unless it is limited separately.
Limits
| Setting | Value |
|---|---|
| Depth and timeout | Quick depth. 600 s scanner timeout, 10 s per request. Stops itself at 85 % of the scanner timeout. |
| Selecting endpoints | Path contains /graphql, /api/graphql or /gql. No match, no requests. |
| Findings per endpoint | At most one per probe: introspection (Medium, 0.90), depth (Medium, 0.80), batch (Low, 0.70). |
| Auth | Uses the login session's headers when one exists; works without one. |
| Not tested | Field suggestions, alias overloading, directive abuse and authorisation inside resolvers. The scanner sends three fixed requests and never reads the schema it may receive. |