Retour au cours

backend / graphql

Sécurité : profondeur, complexité, autorisation par champ

Leçon 141 exercice

Explication

Ce que vous allez apprendre

  • Comprendre comment une requête imbriquée peut devenir une attaque par déni de service
  • Limiter la profondeur maximale d'une requête acceptée par le serveur
  • Calculer une complexité pondérée, plus fine qu'une simple limite de profondeur brute
  • Vérifier une autorisation champ par champ, pas seulement au niveau des mutations racines
  • Comprendre pourquoi le rate limiting HTTP classique ne suffit pas pour GraphQL

Dans quel contexte ?

Une API GraphQL publique expose un schéma où un produit a une catégorie, qui a des produits, qui ont chacun une catégorie, et ainsi de suite. Un attaquant (ou simplement un client mal écrit) envoie une requête qui imbrique cette relation quinze fois de suite. Sans protection, le serveur tente de résoudre un nombre de champs qui croît de façon exponentielle avec la profondeur, jusqu'à saturer le CPU ou la mémoire du serveur en une seule requête.

D'abord, comprendre pourquoi ce risque est spécifique à GraphQL

En REST, chaque endpoint a une forme de réponse fixe et prévisible : impossible pour un client de faire exploser la charge d'une seule requête en changeant simplement son texte. En GraphQL, le client contrôle la forme de sa requête, y compris son imbrication — ce qui est la force du langage devient aussi une surface d'attaque si rien ne la borde.

Une fois ce risque identifié, la première protection est la plus simple : limiter la profondeur

Une extension comme QueryDepthLimiter compte le nombre de niveaux d'imbrication d'une requête avant de l'exécuter, et la rejette si elle dépasse un seuil raisonnable (souvent entre 6 et 10 niveaux pour une API métier classique). C'est une protection grossière mais efficace contre les cas les plus évidents.

Il reste un problème : la profondeur seule ne dit pas tout du coût réel

Une requête peu profonde mais qui demande une liste de 10 000 éléments, chacun avec plusieurs champs coûteux, peut être plus chère qu'une requête profonde mais étroite. C'est pourquoi une limite de complexité pondérée va plus loin : elle attribue un coût à chaque champ (scalaire, objet, liste) et additionne ces coûts sur toute la requête avant de l'exécuter.

ProtectionCe qu'elle mesureLimite
Limite de profondeurNombre de niveaux d'imbricationNe capture pas le coût d'une liste large mais peu profonde
Limite de complexité pondéréeCoût total estimé (scalaires + objets + facteur liste)Nécessite de calibrer les poids par type de champ
Directive @cost personnaliséeCoût explicite déclaré champ par champLe plus précis, mais demande un effort de modélisation

Ensuite, un point souvent négligé : l'autorisation ne s'arrête pas à la racine

Vérifier les droits uniquement sur les mutations de Mutation ne suffit pas : un champ sensible comme coutAchat sur Produit, accessible depuis n'importe quelle query qui traverse ce type, doit vérifier lui-même les droits de l'utilisateur courant. Un attaquant authentifié avec un compte normal pourrait sinon accéder à des données réservées aux administrateurs simplement en les sélectionnant dans une query de lecture, sans jamais passer par une mutation surveillée.

Piège fréquent

Se dire "j'ai déjà un rate limiting sur mon API HTTP, GraphQL est protégé" est une erreur de raisonnement classique : un rate limiting classique compte des REQUÊTES, pas leur coût. Une seule requête GraphQL peut être arbitrairement coûteuse pour le serveur ; limiter le nombre de requêtes par minute ne protège en rien contre une requête individuelle pathologique.

Prérequis

Cette leçon suppose que tu es à l'aise avec les resolvers et le context (leçon 5) : l'autorisation par champ s'implémente directement à l'intérieur des resolvers, en lisant l'utilisateur authentifié depuis le contexte.

Maintenant que tu sais protéger une API GraphQL unique, la dernière étape de ce cours change d'échelle : comment plusieurs équipes peuvent-elles posséder chacune un morceau du schéma, avec la fédération de schémas ?

Commandes & code

Sécurité : profondeur, complexité, autorisation par champ

graphql
# Attaque par requête imbriquée : coût exponentiel côté serveur si aucune limite n'est posée
query AttaqueProfondeur {
  produit(id: "1") {
    categorie {
      produits {
        categorie {
          produits {
            categorie {
              produits { nom }   # imbrication volontairement excessive
            }
          }
        }
      }
    }
  }
}
python
# Limitation de profondeur avec Strawberry (via une extension d'exécution)
import strawberry
from strawberry.extensions import QueryDepthLimiter

schema = strawberry.Schema(
    query=Query,
    extensions=[
        QueryDepthLimiter(max_depth=8),  # rejette toute requête dépassant 8 niveaux d'imbrication
    ],
)
javascript
// Apollo Server : limitation de complexité pondérée (pas seulement la profondeur brute)
import { createComplexityLimitRule } from 'graphql-validation-complexity';

const ComplexityLimitRule = createComplexityLimitRule(1000, {
  scalarCost: 1,
  objectCost: 2,
  listFactor: 10,    // une liste multiplie fortement le coût de ses champs enfants
  onCost: (cost) => console.log('Coût estimé de la requête:', cost),
});

const server = new ApolloServer({
  schema,
  validationRules: [ComplexityLimitRule],
});
graphql
# Coût explicite par champ (directive personnalisée @cost), pour affiner au-delà du calcul générique
directive @cost(valeur: Int!) on FIELD_DEFINITION

type Query {
  produits(limite: Int): [Produit!]! @cost(valeur: 5)
  rechercheGlobale(terme: String!): [ResultatRecherche!]! @cost(valeur: 50)  # coûteux : plusieurs tables
}
python
# Autorisation par champ : ne pas se limiter à l'autorisation au niveau de la mutation racine
import strawberry

@strawberry.type
class Produit:
    nom: str
    prix: float

    @strawberry.field
    def cout_achat(self, info: strawberry.Info) -> float:
        utilisateur = info.context.get("utilisateur")
        if not utilisateur or not utilisateur.est_admin:
            raise Exception("Permission refusée : champ réservé aux administrateurs")
        return self._cout_achat_interne

# Limitation de débit par utilisateur au niveau du endpoint GraphQL entier (pas par requête HTTP)
# car une seule requête GraphQL peut cacher un coût arbitrairement élevé
from fastapi import Request, HTTPException
import time

limites_utilisateur = {}

async def limiter_requetes(request: Request, utilisateur_id: str):
    maintenant = time.time()
    fenetre = limites_utilisateur.setdefault(utilisateur_id, [])
    fenetre[:] = [t for t in fenetre if maintenant - t < 60]
    if len(fenetre) >= 100:
        raise HTTPException(status_code=429, detail="Trop de requêtes GraphQL")
    fenetre.append(maintenant)

Résumé

  • Limiter la profondeur ET la complexité pondérée (une liste imbriquée coûte plus qu'un scalaire simple).
  • L'autorisation doit être vérifiée champ par champ, pas seulement au niveau des mutations racines.
  • Une directive @cost personnalisée affine le calcul de complexité au-delà d'une heuristique générique.
  • Le rate limiting HTTP classique (par requête) est insuffisant : une seule requête GraphQL peut être arbitrairement coûteuse.

Exercices pratiques

1 disponible
1

Mission : bloquer une attaque par requête imbriquée sur une API publique

Objectif : Diagnostiquer une attaque par imbrication excessive, puis mettre en place une limite de profondeur et une autorisation par champ.

Contexte

Le schéma public expose produit -> categorie -> produits -> categorie -> ..., une relation circulaire. Le monitoring détecte une requête qui imbrique cette chaîne une quinzaine de fois, faisant grimper le CPU du serveur en flèche. Au même moment, un audit révèle que le champ coutAchat de Produit, censé être réservé aux administrateurs, est en fait accessible à n'importe quel utilisateur authentifié via une simple query de lecture.

Résoudre l’exercice →