DAST scanner

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.

DASTLow to MediumInfo disclosure / API exposurequick depthno credentials needed

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.

Endpoint inventory POST /graphqlkeep GET /api/Usersskip POST /api/graphqlkeep GET /rest/v1/tasksskip POST /v2/gqlkeep method and query string do not matter Path filter lowercase the URL path, then test whether it contains any of /graphql/api/graphql/gql no match: log it, send nothing Probes per kept endpoint POST /graphql 1 introspection · 2 depth · 3 batch POST /api/graphql 1 introspection · 2 depth · 3 batch POST /v2/gql 1 introspection · 2 depth · 3 batch Before each endpoint: budget.expired()? If so, stop and return what was found.

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.

probesendsresponse must satisfyfinding Introspectionprobe 1schema is public { __schema { types { name fields { name }}}}POST · Content-Type: application/json status 2xx AND body contains__schema or __type or "types"a substring test on the raw text; no JSON parsing MEDIUMconfidence 0.90info disclosure Depthprobe 2no nesting limit { a { a { a { … a { __typename }}}…}}10 levels of a field named "a" status 2xx AND ( no "error" in the bodyOR the word "data" appears )case-insensitive; read as "the server did not reject it" MEDIUMconfidence 0.80exposed API Batchprobe 3many queries, 1 call [ {"query":"{__typename}"}, {"query":"{__typename}"} ]a JSON array of two queries status 2xx AND body starts with [AND contains "__typename"an array back means the server ran both queries LOWconfidence 0.70exposed API

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.

What a string match can and cannot see

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.

A · no error text 200 OK{"data":{"__typename": "Query"}} the nesting went through FINDING · no depth limit B · error, no "data" 200 OK{"errors":[{"message": "Query depth exceeds 10"}]} the server refused it clean · nothing reported C · error and "data" 200 OK{"data":null,"errors":[ {"message":"…"}]} refused, but the word is present FINDING · the rule says accepted query sent, ten levels deep: { a { a { a { a { a { a { a { a { a { a { __typename } } } } } } } } } } } rule: status 2xx AND ( "error" not in body OR "data" in body )

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

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.

Real-world breach

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
})

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

SettingValue
Depth and timeoutQuick depth. 600 s scanner timeout, 10 s per request. Stops itself at 85 % of the scanner timeout.
Selecting endpointsPath contains /graphql, /api/graphql or /gql. No match, no requests.
Findings per endpointAt most one per probe: introspection (Medium, 0.90), depth (Medium, 0.80), batch (Low, 0.70).
AuthUses the login session's headers when one exists; works without one.
Not testedField suggestions, alias overloading, directive abuse and authorisation inside resolvers. The scanner sends three fixed requests and never reads the schema it may receive.