Der GraphQL-Abfrage, der den Datenbank-Pool erschöpfte
Ein verschachteltes GraphQL-Dokument hat eine Produktionsdatenbank lahmgelegt. Warum Rate Limits, HTTP-Timouts und DataLoader versagten – sowie die vier Maßnahmen, die es schließlich unter Kontrolle brachten.
Ein einziger GraphQL POST brachte die API fast eine Stunde lang aus dem Betrieb – und es lag keine Böswilligkeit vor
Das Problem begann kurz nach acht an einem Samstagabend.
Der CPU-Auslastung von Postgres war die volle Kapazität erreicht. Im Verbindungspool waren keine freien Verbindungen mehr vorhanden. Die Clients erhielten überall Timeout-Meldungen. Die öffentliche API war am Sonntagabend völlig nicht verfügbar, was für das Nutzungsverhalten dieses Produkts nahezu der Höchstwert ist.
Das Datenvolumen wirkte normal – sogar etwas gering für den Tag – sodass ein plötzlicher Anstieg unwahrscheinlich schien.
Seit Mitte der Woche war nichts neu veröffentlicht worden, daher war auch ein fehlerhafter Deployment unwahrscheinlich.
Nach etwa einer Viertelstunde Nachforschungen wurde der Übeltäter gefunden – auf den ersten Blick schien es absurd zu sein.
Nur ein einziger HTTP-Aufruf. Ein POST /graphql-Aufruf hatte bereits etwa eineinhalb Minuten damit verbracht, Datenbankoperationen auszuführen, und war immer noch aktiv. Diese einzige Operation hatte bereits mehr Zeit in der Datenbank verbracht als der gesamte normale Verkehr der vorangegangenen Stunden.
Im Folgenden wird die Struktur des Dokuments rekonstruiert, erklärt, warum bestehende Kontrollmechanismen sie übersehen haben, sowie vier Grenzwerte, die einige Tage später ermittelt wurden. Ganz am Ende steht der schlimmste Fall: Wie geringe Anstrengungen ein bösartiger Akteur gegen denselben Sicherheitsfehler aufwenden müsste.
Die Abfrage
Strukturell korrekt – wenn auch verkürzt – sah das Dokument wie folgt aus:
query {
organizations {
members {
user {
organizations {
members {
user {
organizations {
members {
user { id, email }
}
}
}
}
}
}
}
}
}
Durch Verschachtelungen wurden sieben Ebenen in einem Kreislauf durchlaufen: Organisationen enthalten Mitgliedschaften, diese verweisen auf Personen, und die Personen gehören wiederum zu Organisationen.
Diese Kanten sind real und zweirichtig, daher ist eine Modellierung auf diese Weise sinnvoll. Die Resolver haben ordnungsgemäß funktioniert. Jeder einzelne SQL-Aufruf war in Ordnung und schnell.
Der Schaden entstand durch das kombinatorische Wachstum.
Typische Konten befinden sich in etwa drei Organisationen. Typische Organisationen umfassen rund fünfzig Personen. Die Erweiterung dieser Struktur ergibt folgende Zahlen:
- Tiefe 1 → ~3 Organisationen
- Tiefe 2 → ~150 Mitgliedschaften
- Tiefe 3 → ~150 Benutzer
- Tiefe 4 → ~450 Organisationen
- Tiefe 5 → ~22.500 Mitgliedschaften
- Tiefe 6 → ~22.500 Benutzer
- Tiefe 7 → ~67.500 Organisationen
Am Ende sind fast siebzigtausend Elemente vorhanden, wovon jedes weitere Abfragen nach Mitgliedschaften mit sich bringt. Die Erweiterung ging weiter, als die Operator die Arbeit stoppten.
Ein kurzes Textdokument. Keine Authentifizierungslücke. Keine einfügbaren Zeichenketten. Nichts, was von einem Scanner erkannt würde. Das Schema folgte einfach dem Graphen, den es veröffentlichte.
Zufällig, nicht feindlich
Die Herkunft ist für den Unterricht wichtig.
Der Anruf erfolgte über eine authentifizierte Sitzung eines Mitarbeiters. Ein Mobile-Engineer hatte im Apollo Studio am Schema herumgespielt, während er die Anforderungen an UI-Daten skizzierte, und dabei verschachtelte Felder geöffnet, um zu sehen, was vorhanden war.
Er drückte auf Ausführen, beobachtete, wie die UI blockierte, machte das Netzwerk dafür verantwortlich und schloss den Browser-Tab.
Das Schließen eines Tabs beendet die Serverseitigen Verarbeitungen nicht. Der Socket verschwand; die Ausführung setzte sich fort; die Datenbank verarbeitete weiterhin Zehntausende von Abfragen, ohne dass jemand auf die Antwort wartete.
Erst aus dem Thread zum Vorfall am Montag erfuhr er von dem Ausfall. Es geschah nichts Feindliches: Er benutzte den Explorer, den das Unternehmen ihm zur Verfügung gestellt hatte, und arbeitete mit dem Schema, das das Unternehmen bereitgestellt hatte.
Warum die bestehenden Schutzmaßnahmen versagten
Kontrollmechanismen waren vorhanden. Keiner passte zu diesem Fehlermodus – und genau dieses Nichtpassen ist der Punkt.
Beschränkungen für Anfragen pro IP. Die Grenzen beziehen sich auf die Anzahl der HTTP-Aufrufe pro Minute. Ein Aufruf zählt als ein einziger Aufruf. Der Limitierer ließ ihn korrekt durch.
Fristen für Edge-HTTP-Aufrufe. Ein 30-sekündiger Timeout des Balancers trat ein; der Aufrufer erhielt die Fehlermeldung 504. Die Backend-SQL-Abfragen liefen weiter, da das Schließen des Sockets die darin laufenden Aufgaben nicht beendet. Die Clients wurden getäuscht; der Server arbeitete weiter.
DataLoader. Teams betrachten Batching oft als Sicherheitsnetz.
Innerhalb eines Zeitintervalls konsolidiert DataLoader doppelte Ladevorgänge von Entitäten und behebt so effektiv das klassische N+1-Problem. Auf der tiefsten Ebene der Mitgliedschaften werden Tausende von Abfragen in wenige WHERE id IN (...)-Anweisungen zusammengefasst.
Das Zusammenfassen von etwas mehr als zwanzigtausend IDs in einer einzigen Anweisung macht diese Zeilen nicht frei. Die Wege werden kürzer, die Kardinalität jedoch nicht; tiefere Ebenen existieren weiterhin. Batching ist eine Effizienzoptimierung, kein Limit. Effizienz wurde fälschlicherweise für ein festes Limit gehalten.
Anmelden und Identität. Der Aufrufer war angemeldet. Die Identität beantwortet die Frage wer, niemals wie teuer.
Berechtigungen pro Feld. Jedes ausgewählte Feld war für diesen Benutzer erlaubt. Die Autorisierung war erfolgreich. Das Problem lag im Umfang der legitimen Graphenwege, nicht in verbotenen Daten.
Wie hässlich derselbe Schwachpunkt unter Angriffen aussieht
Nach der Wiederherstellung wurde ein Nachmittag damit verbracht, den feindlichen Einsatz desselben Schwachpunkts zu modellieren. Genau dieses Modellieren ist der Grund für dieses Dokument.
Alias ermöglichen es einem Dokument, ein Feld mit unterschiedlichen Argumenten zu wiederholen:
mutation {
a1: login(email: "target@company.com", password: "000001") { token }
a2: login(email: "target@company.com", password: "000002") { token }
a3: login(email: "target@company.com", password: "000003") { token }
# ... two thousand more
}
Noch eine HTTP-Anfrage. Die Rate-Limits gelten für eine einzige Anfrage. Ein Login-Lockout-Zähler, der nach fünf Fehlversuchen ausgelöst wird, befand sich im Resolver und zählte korrekt Tausende von Versuchen.
Dadurch wurde der Passwort-Spray versehentlich gestoppt – der Zähler lag zufällig dort, wo die eigentlichen Abläufe stattfanden.
Das allgemeine Muster blieb unverändert. Jeder teure Resolver konnte innerhalb einer einzigen Anfrage Hunderte Male als Alias verwendet werden, wobei die Rate-Limits ignoriert wurden: Suchvorgänge, Berichterstellung von Aufgaben, Aufrufe an Drittanbieter. Jahrelange REST-basierte Throttling-Strategien standen einer API gegenüber, die sich nicht wie REST verhielt.
Auch in der Produktionsumgebung blieb die Introspektion aktiviert. Jeder konnte das vollständige Typen-Graphen herunterladen – inklusive aller Verbindungen – und ohne Raten Dokumente mit maximalen Kosten erstellen.
Das hat niemand getan. Glück ist keine Kontrollmaßnahme.
Vier Grenzen, die später hinzugefügt wurden
Nach einigen Tagen Arbeit wurden die folgenden Maßnahmen hinzugefügt, sortiert nach ihrem Einfluss.
1. Tiefenbeschränkung
Zuerst und einfachst: Dokumente, die über eine feste Obergrenze hinaus verschachtelt sind, werden abgelehnt.
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
schema,
validationRules: [depthLimit(7)]
});
Es wurde der tatsächliche Clientverkehr untersucht. Die tiefsten legitimen Operationen endeten bei fünf Ebenen. Die Obergrenze wurde auf sieben erhöht – um Raum für Wachstum zu schaffen und pathologische Fälle bereits vor dem Start der Resolver abzulehnen.
Die Validierungsregeln prüfen das analysierte Dokument vor der Ausführung, sodass die Ablehnung nahezu kostenlos ist.
2. Analyse der Abfragenkosten
Tiefe allein reicht nicht aus. Ein flaches Dokument, das zehntausend Listenelemente anfordert, ist dennoch extrem aufwendig zu verarbeiten.
Die Kostenbewertung gewichtet die Felder, multipliziert diese mit den Listenelementen und lehnt Gesamtkosten ab, die einen bestimmten Budgetrahmen überschreiten.
const server = new ApolloServer({
schema,
plugins: [
createComplexityPlugin({
maximumComplexity: 1000,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 })
]
})
]
});
Die Verkabelung ist einfach; die Auswahl der Gewichte erfordert hingegen Arbeit. Skalare kosten eins. Listen kosten erste Mal den Kindkostenwert. Resolver, die auf Dritte verweisen, erhalten manuelle Gewichte wie beispielsweise fünfzig.
Zwei Tage des Feintunens anhand der Produktionsprotokolle brachten die Werte annähernd in Ordnung. Annähernd richtig war ausreichend.
3. Beschränkungen für Aliase und Node-Zahlen
Setzen Sie Obergrenzen für Aliase pro Operation sowie für die Gesamtzahl der AST-Node.
Fünfzig Aliase zusammen mit einer Obergrenze für Node-Zahlen reichten aus, um die Realität abzubilden; kein seriöser Client näherte sich diesen Werten. Zu viele Aliase führen stattdessen zu Validierungsfehlern anstelle zu komplexen Lösungsproblemen.
Paketierte Schutzmechanismen helfen dabei. GraphQL Armor bietet Kontrollmöglichkeiten bezüglich Tiefe, Kosten, Aliase, Direktiven und Introspektion. Teams, die von Grund auf entwickeln, sollten dieses Paket installieren und einstellen, anstatt jedes Element einzeln neu zu erfinden.
4. Zeitlimit für Abfragen in der Datenbank
Die letzte Verteidigungslinie – und der schnellste Weg zum Erfolg:
ALTER ROLE api_user SET statement_timeout = '10s';
Ausführungen unter der App-Rolle verfallen nach zehn Sekunden. Nicht das HTTP-Socket – sondern der SQL selbst. Allein das hätte die Ausfallzeit von etwa 94 Sekunden Datenbankarbeit auf zehn Sekunden reduziert, ohne jegliches GraphQL-Wissen erforderlich zu sein.
Die Introspektion in der Produktion wurde in derselben Woche über die Konfiguration deaktiviert – eine grundlegende Vorsichtsmaßnahme, die bisher übergangen worden war.
Lektionen für eine frühere Version desselben Teams
Drei Erinnerungen.
Beschränken Sie die Kosten, nicht nur die Anzahl der Anfragen. Die Erfassung der Anfragen pro Minute ist eine REST-Gewohnheit. GraphQL kann beliebige Aufgaben in einer einzigen POST-Anfrage verbergen. Wenn das Messinstrument nur Anfragen erfasst, gibt es kein echtes Kostenmesssystem. Messen Sie die tatsächlichen Kosten.
Fristen müssen die Arbeit beenden. Eine 30-sekündige HTTP-Timerout schien schützend zu wirken, verbarg aber nur die Schäden vor den Aufrufern, während die Backend-Systeme weiterhin arbeiteten. Setzen Sie Timerouts dort ein, wo die Arbeit stattfindet – für Postgres statement_timeout auf der Rolle.
DataLoader beschränkt die Größe nicht. Das Batching beseitigt das N+1-Problem und macht größere Abfragen pro Datenübertragung günstiger – es macht sie jedoch nicht kleiner. Effizienz und Obergrenzen sind getrennte Probleme; liefern Sie beides.
Checkliste für produktives GraphQL
Überprüfen Sie diese bald. Die meisten Überprüfungen dauern nur wenige Minuten.
- Ist die Introspektion in der Produktion deaktiviert? Andernfalls ist das gesamte Schema öffentlich zugänglich.
- Ist eine Tiefenobergrenze festgelegt? Messen Sie die tiefsten legitimen Abfragen und legen Sie die Obergrenze etwas darüber.
- Ist ein Kostenbudget festgelegt? Allein die Tiefenbeschränkung berücksichtigt keine langen Listen.
- Ist eine Alias-Obergrenze festgelegt? Oft übersehen; verhindert unkontrolliertes Verteilen von Abfragen.
- Datenbankrolle – ist
statement_timeoutkonfiguriert? Dieser Wert gibt die Arbeitszeit in Minuten an und umfasst diese gesamte Kategorie.
Dieses Team begann mit einem von sechs Systemen. Heute werden alle sechs genutzt; vier kamen bereits am Nachmittag an.
Die Regel
Bleiben Sie bei dieser Unterscheidung:
REST-Endpunkte begrenzen die Arbeitslast durch ihre Konstruktion. GraphQL ermöglicht es den Clients, die Arbeitslast zu begrenzen. Sofern der Server keine explizite Begrenzung wiederherstellt, wurde die Begrenzung nicht verschoben – sie wurde gelöscht.
Frühere Schutzmaßnahmen setzten voraus, dass die Server entscheiden, wie teuer ein Aufruf sein darf. GraphQL überlässt diese Entscheidung demjenigen, der das Dokument schreibt – was mächtig ist und ein häufiger Grund für die Adoption von GraphQL. Die Verantwortung muss im Code übernommen werden; das Framework tut dies nicht.
Fast eine Stunde Stillstand begann, als ein Kollege in einem Studio auf „Ausführen“ klickte. Das ist die freundliche Version. Die feindselige Variante erforderte lediglich ein Konto und einen kurzen Gedanken; sie ist nie eingetreten, weil niemand es versucht hat.
Stellen Sie zuerst die Einstellungen zur Selbstreflexion fest. Das dauert nur Sekunden, und viele Teams kennen die Antwort bereits.