Depth, complexity and hostile queries

GraphQL Course · lesson 14 of 14 · 6 min read

A public GraphQL endpoint lets a client write its own denial of service.

Open this lesson in the learning hub

Key points

  • The flexibility that makes GraphQL good for clients also lets one craft a query that costs enormously more to serve than it costs to send.
  • A schema with a cycle - user has orders, order has a user - allows arbitrarily deep nesting, and each level multiplies the work.
  • Depth limiting is the crude, essential first control: reject anything nested beyond a sensible bound, usually around 10.
  • Complexity analysis is better: assign a cost to each field, multiply by requested list sizes, and reject above a budget. It catches the wide-and-shallow query depth limiting misses.
  • Always bound list sizes. A first argument with no maximum is a request to return the whole table, and a default is not a limit.
  • For a known set of clients, persisted queries end the problem: the server accepts only queries it has been given in advance, by hash. Arbitrary queries are then simply not possible.

Example

# THE ATTACK - a small query, an enormous response.
query Bomb {
  user(id: "1") {
    orders(first: 100) {
      edges { node { customer {
        orders(first: 100) {
          edges { node { customer {
            orders(first: 100) { edges { node { id } } }
          } } }
        }
      } } }
    }
  }
}
#   ~200 bytes to send. Up to 1,000,000 nodes to resolve.

---
# Spring for GraphQL - the two limits worth having from day one.
@Bean
GraphQlSourceBuilderCustomizer limits() {
    return builder -> builder.configureGraphQl(graphQl -> graphQl
            .instrumentation(List.of(
                    new MaxQueryDepthInstrumentation(10),
                    new MaxQueryComplexityInstrumentation(1000))));
}

# Bound list sizes in the SCHEMA, and enforce them in the resolver:
@QueryMapping
public OrderConnection orders(@Argument Integer first) {
    int size = Math.min(first == null ? 20 : first, 100);   // HARD cap
    return service.page(size);
}

---
# PERSISTED QUERIES - the strongest control for a known client set.
#
#   build time: client extracts its queries and registers them
#     sha256("query GetUser...") -> "a1b2c3..."
#
#   runtime: the client sends only the HASH
#     POST /graphql { "extensions": { "persistedQuery": {
#       "version": 1, "sha256Hash": "a1b2c3..." } } }
#
#   The server refuses anything it does not recognise, so an attacker
#   cannot express a novel query at all. Smaller requests too.

---
# Also worth doing, in order of value:
#   - introspection OFF in production (do not hand over the map)
#   - per-operation timeout, so one query cannot run for minutes
#   - rate limit by COMPLEXITY consumed, not by request count
#   - log operationName + complexity, so you can see what is expensive
#   - break schema cycles where the reverse edge is not actually needed

A 200-byte query can request a million nodes - cap depth and complexity, bound every list, and prefer persisted queries.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the GraphQL Course course, and every lesson in it is listed on the GraphQL Course contents page.