Blog

GraphQL absichern: Query-Tiefe, Komplexität und Rate-Limiting

Ein einziger GraphQL-Endpunkt nimmt beliebig verschachtelte Queries an – das ist bequem und zugleich ein Missbrauchsrisiko. Ich zeige, wie ich mit Depth-Limit, Complexity-Budget, Pagination-Caps und Rate-Limiting eine mehrschichtige Verteidigung gegen teure Queries baue.

≈ 8 Min. Lesezeit

Diesen Beitrag anhören (11 Min.)

MP3 herunterladen

Bei einer REST-API ist das Kostenprofil einer Route meist enger begrenzt: Wer /users/1/posts baut, kennt den vorgesehenen Ausführungspfad, auch wenn Parameter und Datenmenge die tatsächlichen Kosten weiter verändern. GraphQL gibt dem Aufrufer mehr Einfluss auf Form und Tiefe der Abfrage. Für die Developer Experience ist das ein Gewinn. Für den Betrieb heißt es: Die Kosten eines Requests lassen sich erst aus dem eingehenden Dokument und dem Resolver-Verhalten abschätzen.

Die Frage, an der sich alles entscheidet, lautet: Was passiert, wenn jemand eine zwanzigfach verschachtelte Query mit first: 1000 auf jeder Ebene schickt? Wer darauf keine Antwort hat, betreibt eine API, die beim ersten unfreundlichen Client umfällt – und merkt es genau dann zum ersten Mal.

In diesem Beitrag geht es nicht um Authentifizierung oder Autorisierung – das habe ich in GraphQL-Auth im Context behandelt. Hier geht es um etwas Orthogonales: den Schutz vor teuren und böswilligen Queries. Ein sauber authentifizierter, autorisierter Nutzer kann den Server trotzdem in die Knie zwingen. Wer nur Auth hat, ist gegen diese Klasse von Problemen ungeschützt.

Warum ein Endpunkt gefährlich wird

Das Kernproblem sind zyklische Beziehungen im Schema. Sobald ein User seine posts hat und ein Post seinen author – der wiederum seine posts hat – ist die Tiefe formal unbegrenzt:

query MaliciousDepth {
  user(id: "1") {
    posts {
      author {
        posts {
          author {
            posts {
              author {
                # ... and so on, as deep as the client wants
                name
              }
            }
          }
        }
      }
    }
  }
}

Jede zusätzliche Ebene kann die Zahl der Resolver-Aufrufe vervielfachen. Ob daraus auch entsprechend viele Datenbankzugriffe werden, hängt von Batching, Caching und den Resolvern ab. Kombiniert mit unbegrenzten Listen kann dennoch eine extrem teure Query entstehen – ein Denial-of-Service mit einem einzigen HTTP-Request. Das ist kein hypothetisches Lehrbuch-Szenario. Ich habe produktive APIs gesehen, die genau so kippten, weil ein Frontend-Bug versehentlich eine rekursive Query erzeugte.

Die gute Nachricht: Verteidigung funktioniert hier gut in Schichten. Keine einzelne Maßnahme genügt, aber die Kombination macht die Angriffsfläche beherrschbar. Der Ablauf, den ich anstrebe, sieht so aus:

flowchart TD
  A[Client-Request] --> B{Rate-Limit<br/>am Edge}
  B -->|Budget überschritten| R1[429 Too Many Requests]
  B -->|ok| C{Depth Gate<br/>graphql-depth-limit}
  C -->|zu tief| R2[Validation Error]
  C -->|ok| D{Complexity Gate<br/>Cost-Budget}
  D -->|über Budget| R3[Validation Error]
  D -->|ok| E[Resolver-Ausführung<br/>mit Pagination-Caps]
  E --> F[Response]

Die entscheidende Eigenschaft: Depth- und Complexity-Prüfung greifen in der Validierungsphase, also bevor ein einziger Resolver läuft. Parsing und Validierung kosten ebenfalls Ressourcen, bleiben mit Größen- und Ratenlimits aber deutlich besser begrenzbar als die eigentliche Resolver-Ausführung.

Schicht 1: Query-Tiefe begrenzen

Der einfachste und wirkungsvollste erste Schritt ist ein Limit auf die Schachtelungstiefe. In Apollo Server 4 hängt man dafür eine Validation Rule in die validationRules des Konstruktors. Die verbreitete Bibliothek dafür ist graphql-depth-limit:

import { ApolloServer } from "@apollo/server";
import depthLimit from "graphql-depth-limit";
import { typeDefs } from "./schema";
import { resolvers } from "./resolvers";

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    // Reject any operation nested deeper than 7 levels.
    depthLimit(7),
  ],
});

depthLimit(maxDepth) liefert eine graphql-js Validation Rule, die während der Validierung die tiefste Verschachtelung zählt und Operationen über dem Limit ablehnt. Introspektionsfelder wie __schema und __type werden per Default ignoriert, damit Tools weiter funktionieren.

Ein Wort der Warnung, das 2024 dazugehört: graphql-depth-limit (v1.1.0) wird seit 2018 nicht mehr gepflegt und zählt ausschließlich die Selektionstiefe – nicht die Tiefe von Listen. Für viele Projekte reicht das als erste Schicht, solange die Lücke bekannt ist. Wer strenger sein will, kann sich @graphile/depth-limit ansehen, das auch List-Tiefe berücksichtigt.

Wichtig ist die konzeptionelle Grenze: Depth-Limiting kennt keine Feld-Kosten. Eine flache Query, die dafür tausend teure Felder auf einer Ebene selektiert, läuft anstandslos durch. Tiefe ist eben nur eine Dimension des Missbrauchs.

Schicht 2: Complexity- und Cost-Analyse

Damit sind wir bei der zweiten Schicht, die die Schwäche der ersten ausgleicht. Statt nur Tiefe zu zählen, vergebe ich Kosten pro Feld und lehne jede Query ab, die ein Gesamtbudget überschreitet. Das etablierte Werkzeug dafür ist graphql-query-complexity von slicknode.

Der Kern ist createComplexityRule, das ich mit einem maximumComplexity-Budget und einer Liste von Estimators konfiguriere. Estimators sind die Regeln, nach denen Kosten berechnet werden – sie werden der Reihe nach befragt, bis einer einen Wert liefert:

import { ApolloServer } from "@apollo/server";
import depthLimit from "graphql-depth-limit";
import {
  createComplexityRule,
  simpleEstimator,
  fieldExtensionsEstimator,
} from "graphql-query-complexity";

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(7),
    createComplexityRule({
      maximumComplexity: 1000,
      estimators: [
        // Prefer explicit costs declared on the schema fields...
        fieldExtensionsEstimator(),
        // ...and fall back to a flat cost of 1 per field.
        simpleEstimator({ defaultComplexity: 1 }),
      ],
      onComplete: (complexity) => {
        console.log(`Query complexity: ${complexity}`);
      },
    }),
  ],
});

Die eigentliche Modellierung passiert an den Feldern selbst. Über fieldExtensionsEstimator lese ich Kosten aus den Schema-Extensions – und genau hier bilde ich Listen realistisch ab. Ein Feld, das eine Liste zurückgibt, kostet nicht konstant, sondern proportional zum angeforderten first-Wert:

import { GraphQLObjectType, GraphQLList } from "graphql";
import type { ComplexityEstimatorArgs } from "graphql-query-complexity";

const UserType = new GraphQLObjectType({
  name: "User",
  fields: {
    posts: {
      type: new GraphQLList(PostType),
      args: { first: { type: GraphQLInt } },
      extensions: {
        // Cost scales with the number of requested items.
        complexity: ({ args, childComplexity }: ComplexityEstimatorArgs) => {
          const requested = args.first ?? 20;
          return childComplexity * requested;
        },
      },
    },
  },
});

Das ist der Punkt, an dem die Cost-Analyse ihre Stärke ausspielt. Eine tiefe Query mit kleinen Listen kann günstiger sein als eine flache mit riesigen. Das Budget von 1000 ist dabei kein magischer Wert – ich leite ihn aus realen Query-Mustern ab, indem ich onComplete erst einmal nur loggen lasse und beobachte, wo legitime Clients landen, bevor ich den Riegel scharf schalte.

Ein Detail zur Apollo-Server-4-Integration, das gern Zeit kostet: Für die reine Ablehnung reicht die Validation Rule oben. Wer die Komplexität aber im Request-Lifecycle weiterverarbeiten will – etwa fürs Kosten-Accounting pro API-Key – nutzt ein eigenes Plugin und berechnet die Komplexität im didResolveOperation-Hook mit getComplexity(). Der fertige AS3-Plugin-Wrapper createComplexityPlugin passt nicht mehr direkt in AS4, deshalb ist der Plugin-Weg von Hand kurz erwähnenswert:

import type { ApolloServerPlugin } from "@apollo/server";
import { getComplexity, simpleEstimator } from "graphql-query-complexity";
import { separateOperations } from "graphql";

const complexityPlugin: ApolloServerPlugin = {
  async requestDidStart() {
    return {
      async didResolveOperation({ request, document, schema }) {
        const complexity = getComplexity({
          schema,
          operationName: request.operationName,
          query: request.operationName
            ? separateOperations(document)[request.operationName]
            : document,
          variables: request.variables,
          estimators: [simpleEstimator({ defaultComplexity: 1 })],
        });

        if (complexity > 1000) {
          throw new Error(
            `Query is too expensive: ${complexity}. Maximum allowed: 1000.`,
          );
        }
      },
    };
  },
};

Schicht 3: Pagination-Obergrenzen

Die Cost-Regel kann Listen nur dann korrekt bewerten, wenn ich Listen überhaupt begrenze. Ein first-Argument ohne serverseitiges Maximum ist eine offene Flanke: Ein einziger Aufruf mit first: 100000 löst zehntausende Resolver aus. Deshalb klemme ich jeden Pagination-Parameter serverseitig ab, unabhängig davon, was der Client wünscht:

const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;

const resolvers = {
  User: {
    posts: (parent, args, context) => {
      // Never trust the client-supplied page size.
      const first = Math.min(args.first ?? DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE);
      return context.dataSources.posts.byAuthor(parent.id, { first });
    },
  },
};

Wer sein Schema sauber nach dem Connections-Muster baut, hat hier ohnehin einen natürlichen Ort für diese Grenze – wie das strukturell aussieht, habe ich in GraphQL-Paginierung und Connections beschrieben. Die Cap gehört in den Resolver oder eine gemeinsame Helper-Funktion, nicht in die Dokumentation als Bitte an den Client.

Schicht 4: Rate-Limiting

Depth und Complexity begrenzen die Kosten einer einzelnen Query. Sie sagen nichts darüber, wie viele Queries pro Sekunde jemand feuert. Rate-Limiting ist kein Apollo-Kern-Feature, und meiner Erfahrung nach ist die zuverlässigste Stelle dafür die Transport-Ebene – ein Reverse-Proxy oder eine Middleware vor dem GraphQL-Handler:

import express from "express";
import rateLimit from "express-rate-limit";

const app = express();

const limiter = rateLimit({
  windowMs: 60_000, // one minute
  max: 120, // limit each IP to 120 requests per window
  standardHeaders: true,
  legacyHeaders: false,
});

app.use("/graphql", limiter);

Für feingranulare Limits pro Feld gibt es Directive-basierte Ansätze wie graphql-rate-limit mit einer @rateLimit-Directive im Schema. Das ist mächtig, aber auch mehr Betriebskomplexität. Den größten Teil trägt ein grobes Limit am Edge; feingranulare Directives lohnen erst dort, wo ein konkretes Feld nachweislich Ärger macht.

Persisted Queries und Allowlisting – mit Vorsicht

An dieser Stelle kommt regelmäßig der Vorschlag: „Lass uns Automatic Persisted Queries (APQ) einschalten, dann ist das Problem gelöst.“ Das ist ein verbreitetes Missverständnis, das ich klarstellen möchte.

APQ ist ein Performance-Feature. Der Client sendet statt der vollen Query nur einen SHA-256-Hash; kennt der Server den Hash nicht, antwortet er mit PERSISTED_QUERY_NOT_FOUND, der Client schickt die Query einmalig nach, und der Server cached sie. Das spart Bandbreite. Es ist keine Allowlist – APQ registriert jede beliebige eingehende Operation automatisch, auch die böswillige.

const server = new ApolloServer({
  typeDefs,
  resolvers,
  // Performance optimization, NOT a security control.
  persistedQueries: { ttl: 900 },
});

Echtes Safelisting ist etwas anderes: eine vorab registrierte Liste erlaubter Operationen, gegen die der Server prüft. Alles, was nicht auf der Liste steht, wird abgelehnt – egal wie tief oder teuer. In der Apollo-Welt läuft das über eine Persisted Query List in GraphOS zusammen mit dem Apollo Router, ein Enterprise-Feature, bei dem APQ dann bewusst deaktiviert ist. Für interne APIs mit einem bekannten Satz von Client-Operationen ist das die stärkste Verteidigung überhaupt, weil die Menge möglicher Queries endlich und geprüft wird. Für eine öffentliche API mit beliebigen Clients ist es dagegen keine Option – dort tragen Depth und Complexity die Last.

Introspection in Produktion

Der letzte Baustein wird oft überschätzt. Das Abschalten der Introspection in Produktion (introspection: false) verhindert, dass ein Angreifer das Schema bequem abfragt. Das ist ein sinnvoller Default für nicht-öffentliche APIs – aber ich nenne es bewusst nicht Sicherheit, sondern Bequemlichkeitsentzug für Angreifer. Es ist Security by Obscurity: Wer das Schema kennt oder rät, kommt an jeder deaktivierten Introspection vorbei. Introspection abzuschalten ersetzt kein einziges der echten Limits oben. Es ist ein netter Zusatz, kein Fundament.

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== "production",
});

Dazu gehört auch ein serverseitiger Request-Timeout, damit eine Query, die trotz aller Gates lange läuft, nicht unbegrenzt Ressourcen bindet. Das ist die Notbremse hinter allen anderen Schichten.

Die Schichten im Überblick

Für einen produktiven GraphQL-Endpunkt ziehe ich die Schichten von außen nach innen ein:

  • Rate-Limiting am Edge begrenzt, wie viele Requests pro Zeitfenster überhaupt durchkommen.
  • Persisted Queries oder eine Allowlist lassen bei geschlossenem Client-Kreis nur bekannte Operationen zu.
  • Ein Depth-Limit kappt die Schachtelungstiefe schon vor der Ausführung.
  • Das Complexity-Budget vergibt Kosten pro Feld und lehnt zu teure Queries ab.
  • Pagination-Caps verhindern unbegrenzte Listen und machen die Cost-Analyse überhaupt erst korrekt.
  • Ein Resolver-Timeout ist die Notbremse für alles, was trotzdem durchrutscht.

Keine dieser Schichten ist für sich vollständig. Depth ohne Complexity übersieht breite Queries. Complexity ohne Pagination-Caps rechnet mit falschen Multiplikatoren. Rate-Limiting ohne Cost-Analyse lässt einzelne teure Queries durch. Erst im Zusammenspiel entsteht ein Endpunkt, dessen schlimmster Fall ich kenne und beherrsche.

Fazit

Die Flexibilität von GraphQL verlagert Kontrolle vom Server zum Client – und damit auch die Möglichkeit, den Server zu überlasten. Der häufigste Fehler in der Praxis ist die Annahme, Authentifizierung reiche als Schutz. Sie tut es nicht: Der Angriff über teure Queries ist orthogonal zur Frage, wer der Nutzer ist. Ein legitimer, angemeldeter Client kann durch einen simplen Bug dieselbe DoS-Query erzeugen wie ein Angreifer mit Absicht.

Die Reihenfolge, die sich bewährt hat: Erst das Kostenprofil sichtbar machen (Complexity loggen), dann die Gates von grob nach fein einziehen – Depth-Limit und Pagination-Caps als schnelle Gewinne, Complexity-Budget als eigentliches Rückgrat, Rate-Limiting als Rahmen, Allowlist wo der Client-Kreis es zulässt. Introspection und Timeouts runden ab, tragen aber nicht. Wer diese Schichten hat, kann die Last pro Anfrage wesentlich besser begrenzen und Abweichungen zwischen geschätzten und realen Resolver-Kosten im Betrieb beobachten.

Weiterführende Quellen

Wie fandest du diesen Beitrag?

Kommentare