La requête GraphQL qui a épuisé le pool de bases de données
Un document GraphQL imbriqué a provoqué la panne d’une base de données en production. Pourquoi les limites de débit, les temps d’attente HTTP et DataLoader ont échoué — ainsi que les quatre mécanismes qui ont finalement permis de maîtriser la situation.
Une seule requête GraphQL POST a mis l’API hors ligne pendant près d’une heure — et ce n’était pas intentionnel
Les problèmes ont commencé juste après huit heures, un soir de week-end.
La consommation en CPU de Postgres était à pleine capacité. Le pool ne disposait plus d’aucune connexion disponible. Tous les clients rencontraient des délais de réponse excessifs. L’API publique était complètement inaccessible ce dimanche soir, période généralement charnière pour les utilisations de ce produit.
Le volume du trafic semblait normal — voire un peu faible pour la journée — ce qui rendait peu probable une augmentation soudaine.
Rien n’avait été déployé depuis le milieu de la semaine, donc un déploiement défectueux semblait également peu probable.
Après environ un quart d’heure d’enquête, la cause a été identifiée ; elle paraissait absurde au premier abord.
Une seule requête HTTP. Une POST /graphql avait déjà consommé environ une minute et demie à traiter des opérations sur la base de données et était toujours active. Cette seule opération avait déjà utilisé plus de temps de la base que les quelques heures de trafic normal précédentes.
Ce qui suit reconstitue la structure du document, explique pourquoi les mécanismes de contrôle existants ne l’ont pas détecté, ainsi que quatre scénarios limites découverts quelques jours plus tard. À la fin se trouve le pire des cas : le peu d’efforts nécessaires à un acteur malveillant pour exploiter la même faille.
La requête
Structuralement exacte (bien que raccourcie), le document ressemblait à ceci :
query {
organizations {
members {
user {
organizations {
members {
user {
organizations {
members {
user { id, email }
}
}
}
}
}
}
}
}
}
Une structure en nid d’oiseau s’étendait sur sept niveaux en boucle : les organisations contiennent des adhésions, les adhésions renvoient vers des personnes, et ces dernières appartiennent à nouveau à des organisations.
Ces arêtes sont réelles et bidirectionnelles, donc les modéliser de cette manière est approprié. Les résolveurs ont fonctionné correctement. Chaque appel SQL discret s’est déroulé sans problème et rapidement.
Les problèmes sont apparus en raison d’une croissance combinatoire.
Les comptes typiques appartiennent à environ trois organisations. Les organisations typiques comprennent une cinquantaine de personnes. L’expansion de cette structure donne les valeurs suivantes :
- niveau 1 → ~3 organisations
- niveau 2 → ~150 adhésions
- niveau 3 → ~150 utilisateurs
- niveau 4 → ~450 organisations
- niveau 5 → ~22,5 k d’adhésions
- niveau 6 → ~22,5 k d’utilisateurs
- niveau 7 → ~67,5 k d’organisations
Près de soixante-dix mille éléments se trouvent au niveau le plus bas, chacun entraînant davantage de requêtes pour récupérer des adhésions. L’expansion continuait quand les opérateurs ont interrompu le processus.
Un court document texte. Aucune faille de sécurité. Aucune chaîne pouvant être injectée. Rien que les outils de scan ne détectent pas. Le schéma respectait simplement le graphe qu’il publiait.
Accidentel, pas malveillant
L’origine est importante pour la leçon.
L’appel a été effectué depuis une session authentifiée d’un membre du personnel. Un ingénieur mobile explorait le schéma dans Apollo Studio tout en définissant les besoins de données d’interface utilisateur, ouvrant des champs imbriqués pour voir ce qui existait.
Ils ont appuyé sur « exécuter », vu l’interface s’arrêter, blâmé le réseau et fermé l’onglet du navigateur.
Fermer un onglet ne met pas fin aux traitements côté serveur. Le socket a disparu ; l’exécution a continué ; la base de données a traité des dizaines de milliers de chemins sans que personne n’attende la réponse.
Ils n’ont appris l’incident que grâce au fil de discussion du lundi. Rien d’hostile ne s’est produit : ils utilisaient l’outil fourni par l’entreprise, conformément au schéma fourni par celle-ci.
Pourquoi les mécanismes de protection existants n’ont pas fonctionné
Des contrôles étaient présents. Aucun ne correspondait à ce mode de défaillance — et c’est justement cette inadéquation qui est problématique.
Limites des requêtes par IP. Ces limites comptent les appels HTTP par minute. Un appel reste un appel ; le limiteur l’a correctement autorisé.
Délais HTTP Edge. Un temps d’attente de trente secondes du équilibreur a été déclenché ; l’appelant a reçu une réponse 504. Le moteur SQL en arrière-plan a continué à fonctionner, car la fermeture du socket ne met pas fin aux tâches en cours. Les clients ont été trompés ; le serveur a continué à consommer des ressources.
DataLoader. Les équipes considèrent souvent le regroupement des opérations comme une solution de secours.
Dans un temps très court, DataLoader fusionne les chargements redondants d’entités et corrige réellement le problème classique N+1. Au niveau de la couche d’appartenance, des milliers de recherches sont regroupées en quelques instructions WHERE id IN (...).
Réunir un peu plus de vingt mille identifiants en une seule requête ne rend pas ces lignes libres. Les allers-retours diminuent, mais la cardinalité non ; des niveaux plus profonds existent toujours. Le lotage est un ajustement pour améliorer l’efficacité, pas une limite absolue. L’efficacité a été confondue avec une contrainte.
Connexion et identité. L’utilisateur était connecté. L’identité répond à la question qui, jamais combien c’est coûteux.
Droits par champ. Chaque champ sélectionné était autorisé pour cet utilisateur. L’autorisation a réussi. Le problème résidait dans le volume de parcours graphiques légitimes, et non dans des données interdites.
À quel point ce même défaut apparaît problématique en cas d’attaque
Après la récupération, une après-midi a été consacrée à modéliser l’utilisation malveillante de cette même faille. C’est ce modèle qui explique l’existence de ce texte.
Les alias permettent à un document de répéter un champ avec des arguments différents :
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
}
Ce n’était toujours qu’une seule requête HTTP. Les limites de débit en comptaient une également. Un compteur de verrouillage après cinq échecs se trouvait à l’intérieur du résolveur et comptait correctement des milliers de tentatives.
Ainsi, cette attaque par diffusion de mots de passe aurait été stoppée par hasard — le compteur se trouvait justement là où les opérations réelles avaient lieu.
Le schéma général est resté inchangé. Tout résolveur coûteux pouvait être aliasé des centaines de fois au sein d’une seule requête, ce que les limites de débit ignoraient : recherches, génération de rapports, appels à des tiers. Des années de mécanismes de limitation inspirés de REST se heurtaient à une API qui ne se comportait pas comme REST.
En environnement de production, l’option d’introspection était également activée. N’importe qui pouvait récupérer l’ensemble du graphe de types — y compris chaque arête — et créer des documents nécessitant un coût maximal sans avoir à deviner.
Nul ne l’a fait. La chance n’est pas un mécanisme de contrôle.
Quatre limites ajoutées par la suite
Quelques jours de travail d’ingénierie ont permis d’ajouter ce qui suit, classé par impact.
1. Limitation de la profondeur
Tout d’abord et le plus simplement : refuser les documents imbriqués au-delà d’un plafond fixé.
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
schema,
validationRules: [depthLimit(7)]
});
La fréquentation réelle des clients a été étudiée. Les opérations les plus profondes et honnêtes s’arrêtaient à cinq niveaux. Le plafond a été porté à sept — cela laisse de la marge pour la croissance et permet de rejeter les cas pathologiques avant même que les résolveurs ne commencent à fonctionner.
Les règles de validation examinent le document analysé avant son exécution, ce qui rend le rejet pratiquement gratuit.
2. Analyse du coût des requêtes
La profondeur ne suffit pas. Un document peu profond qui demande dix mille éléments de liste reste énorme en termes de volume.
Le système d’évaluation des coûts pondère les champs, multiplie ces valeurs par les arguments de la liste, et rejette les totaux dépassant un budget prédéfini.
const server = new ApolloServer({
schema,
plugins: [
createComplexityPlugin({
maximumComplexity: 1000,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 })
]
})
]
});
Le câblage est simple ; choisir les poids correspondants est la tâche difficile. Les valeurs scalaires coûtent un point. Les listes coûtent premier fois le coût de leurs éléments enfants. Les résolveurs qui font appel à des tiers reçoivent des poids manuels, tels que cinquante.
Deux jours d’ajustements en fonction des journaux de production ont permis d’obtenir des chiffres plus ou moins exacts. C’était suffisant.
3. Limites des alias et du nombre de nœuds
Limitez les aliases par opération ainsi que le nombre total de nœuds AST.
Cinquante aliases associés à un plafond de nœuds couvraient les besoins réels ; aucun client sérieux ne dépassait ces limites. Une utilisation excessive d’aliases entraîne des erreurs de validation plutôt que des problèmes majeurs liés au traitement.
Les outils de protection intégrés aident. GraphQL Armor regroupe des contrôles concernant la profondeur, le coût, les aliases, les directives et l’introspection. Les équipes débutantes devraient installer et ajuster cet outil avant de réinventer chaque composant.
4. Délai d’expiration des requêtes au niveau de la base de données
Dernière ligne de défense — et solution la plus rapide :
ALTER ROLE api_user SET statement_timeout = '10s';
Les requêtes exécutées dans le cadre du rôle de l’application expirent après dix secondes. Il ne s’agit pas du socket HTTP, mais bien de la requête SQL elle-même. Rien que cela aurait permis de réduire la durée d’interruption, passant de ~94 secondes pour les opérations sur la base de données à dix secondes, sans aucune expertise en GraphQL.
L’introspection en production a été désactivée via la configuration la même semaine — une pratique de base qui avait été ignorée dès le début.
Leçons à tirer pour une version antérieure du même équipe
Trois rappels.
Régulez les coûts, et pas seulement le nombre de requêtes. Le comptage des requêtes HTTP par minute est une habitude propre au REST. GraphQL peut cacher n’importe quelle opération complexe dans une seule requête POST. Si le compteur ne prend en compte que les requêtes, il n’y a pas de véritable mesure des coûts.
Les délais impératifs doivent mettre fin aux opérations. Un temps d’attente HTTP de trente secondes semblait protecteur, mais ne faisait que cacher les dommages causés aux utilisateurs tandis que les backends continuaient de fonctionner. Définissez des délais d’attente là où les opérations se déroulent — pour Postgres, utilisez statement_timeout sur le rôle correspondant.
DataLoader ne limite pas la taille. Le regroupement des requêtes élimine le problème N+1 et rend les requêtes volumineuses moins coûteuses par aller-retour, mais il ne les rend pas plus petites. L’efficacité et les limites supérieures sont deux problèmes distincts ; il faut les prendre en compte tous les deux.
Liste de vérification pour GraphQL en production
Vérifiez ces points dès que possible. La plupart ne prennent que quelques minutes.
- L’introspection est-elle désactivée en production ? Sinon, l’ensemble du schéma devient public.
- Un plafond de profondeur a-t-il été défini ? Mesurez la profondeur maximale des requêtes honnêtes ; fixez le plafond légèrement au-dessus de cette valeur.
- Un budget de coût a-t-il été défini ? La profondeur seule ne tient pas compte des listes longues.
- Un plafond pour les alias a-t-il été défini ? Cela est souvent oublié ; cela empêche les schémas trop complexes.
- Rôle de la base de données :
statement_timeoutest-il configuré ? Il indique le temps maximal de traitement d’une requête ; il couvre toute cette catégorie de requêtes.
Cette équipe a commencé avec l’un des six systèmes. Ils en utilisent maintenant tous les six ; quatre sont arrivés en une après-midi.
La règle
Gardez cette distinction à l’esprit :
Les points d’entrée REST limitent automatiquement le travail par conception. GraphQL permet aux clients de définir ces limites. À moins que le serveur ne réinstaure une limite explicite, celle-ci n’a pas été déplacée — elle a été supprimée.
Les mesures de sécurité précédentes partaient du principe que c’étaient les serveurs qui décidaient du coût d’une requête. GraphQL transfère cette responsabilité à celui qui écrit le document — ce qui en fait un outil puissant et une raison fréquente d’en adopter l’utilisation. La responsabilité doit être reprise dans le code ; le framework ne le fera pas.
Près d’une heure d’arrêt a commencé lorsque un collègue a appuyé sur « exécuter » dans un studio. C’est la version « amicale ». La version « hostile » ne nécessitait qu’un compte et une brève réflexion ; cela n’est jamais arrivé simplement parce que personne n’a essayé.
Vérifiez d’abord les paramètres d’introspection. Cela ne prend que quelques secondes, et de nombreuses équipes connaissent déjà la réponse.