GraphQL introspection in production: what an open schema exposes
Open introspection hands anyone a map of every type in your API. squirrel sends one read-only introspection query per endpoint and counts the types it gets back.
What open introspection is
GraphQL introspection is a query that asks the server to describe its own schema: every type, field, argument and mutation. Developer tools rely on it, and many servers leave it on by default, so it often ships to production with the rest of the stack.
In production it gives an attacker the whole API surface in one request: admin mutations, internal fields and arguments nobody meant to document. It does not grant access by itself, but it removes the guesswork from finding the resolver that forgot its authorization check.
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
- GraphQL endpoints the audit found in your pages and scripts, such as a
fetch("/graphql")call site: a URL with agraphqlorgqlpath segment. Finding them sends no request. - The conventional paths
/graphqland/api/graphql.
Every endpoint must have the same origin as the audited URL (scheme, host and port). An endpoint on another host, such as a vendor API, is never probed. An endpoint whose path names a state-changing action, such as logout, delete, update or reset, is never probed either, 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, a GET with the read-only introspection query in the URL:
GET /graphql?query={__schema{types{name}}}The query asks for type names only. It carries no cookies and does not follow redirects, and at most 1 MB of the answer is read. The limit counts the answer after decompression, and a compressed answer is decompressed no further than the limit, so a gzip or brotli bomb is cut at the limit rather than decompressed in full.
On an aggressive run only, one POST of the same query follows when the GET answer shows a GraphQL server but no schema, for example a server that blocks GET requests or asks for a JSON content type. The body is {"query":"{__schema{types{name}}}"} and nothing else. No POST is sent when the GET answer is not GraphQL, or when it already says introspection is disabled.
Results
The rule emits checks named graphql-introspection:
- Skipped when probing is passive. Message:
Probing is passive, so no GraphQL endpoint was probed. Run with --probe active to check for open introspection. - 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. - Warn, one check per endpoint, when the answer is a 2xx GraphQL response with a non-empty list of named types. Message:
GraphQL introspection is open at https://example.com/graphql: the schema lists 214 types, 187 of them defined by the API (answered over GET). The item meta has the method, the type count and the API-defined type count, which leaves out__*introspection types and the built-in scalars. - Warn, as a lower-confidence finding, when a 2xx answer opens as a schema (
{"data":{"__schema":{"types":[) but is longer than the 1 MB read cap. The schema is open, it is just too large to count, so it is never a pass. Message:GraphQL introspection is open at https://example.com/graphql: the schema answer is larger than 1 MB, too large to count (at least 41250 types seen, answered over GET). The item meta hastruncated: trueandconfidence: "lower". - Pass when no endpoint returned a schema. Message:
No GraphQL endpoint returned its schema (2 endpoint(s) probed, 1 answered as GraphQL).
What is not flagged
- An error that says introspection is disabled, or any other GraphQL error.
- A response with no schema, an empty type list or types without names.
- A non-2xx response, a redirect or a network error.
- An HTML page, a catch-all route or a JSON body that is not GraphQL-shaped. A GraphQL response is a JSON object with only
data,errorsandextensionskeys, where each error has amessage.
Limits
The rule counts types, not fields, to keep the request and the answer small. For an answer larger than 1 MB, only the types in the first 1 MB are counted. An endpoint the crawl never referenced and that is not at a conventional path is not found. Introspection that is open only to signed-in users is not seen, because the probes carry no cookies.
How to fix it
Turn introspection off in production. Most servers have one setting for it:
| Server | Setting |
|---|---|
| Apollo Server | introspection: false |
| graphql-js | add NoSchemaIntrospectionCustomRule to the validation rules |
| Hasura | HASURA_GRAPHQL_ENABLE_INTROSPECTION=false, or disable it per role |
| Spring for GraphQL | spring.graphql.schema.introspection.enabled=false |
Keep it on in development, or only for authenticated staff. Turning it off hides the map, not the API: every resolver still needs its own authorization checks.
| Rule ID | security/graphql-introspection |
| Category | Security |
| Scope | Site-wide |
| Severity | warning |
| Weight | 5/10 |
Enable / disable
Disable this rule
[rules]
disable = ["security/graphql-introspection"]Disable all Security rules
[rules]
disable = ["security/*"]Enable only this rule
[rules]
enable = ["security/graphql-introspection"]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.