19 KiB
TP : sécurisation d'une API GraphQL (OWASP)
Vous avez développé une API GraphQL pour une application de prise de notes en utilisant FastAPI et Strawberry. L'application est fonctionnelle, mais n'a aucune notion de sécurité.
Actuellement, n'importe quel utilisateur (même non authentifié) peut lire, rechercher et voir les profils et notes privées de tous les autres utilisateurs. L'API est également vulnérable à des attaques par Déni de Service (DoS) et expose sa configuration en production.
Objectifs du TP
- Implémenter l'Authentification (AuthN) au niveau de la couche Contexte.
- Implémenter l'Autorisation (AuthZ) au niveau des Resolvers (OWASP A01).
- Configurer les défenses de production (OWASP A02 & A06).
- Utiliser les outils fournis (Exceptions, Helpers) pour sécuriser l'API.
Votre Mission
Ce qui est fourni et 100% fonctionnel (ne pas modifier) :
- Tout le package
app/models/(schémas Pydantic) - Tout le package
app/repositories/(simulation de la base de données) - Tout le package
app/core/(contientconfig.pyetexceptions.pyqui définitAuthNExceptionetAuthZException) app/graphql/resolvers/auth_resolver.py(contient le helperget_current_user_from_info)app/graphql/resolvers/noop.pyapp/graphql/types/(les typesNoteetUsersont définis)app/graphql/queries.pyetmutations.py(les points d'entrée sont définis)app/graphql/extensions.py(contient laSecurityErrorExtensionprête à l'emploi pour formater les erreurs)
Ce sur quoi vous allez travailler (fichiers à modifier) :
app/graphql/context.py: pour implémenter l'Authentification.app/graphql/resolvers/note_resolver.pyetuser_resolver.py: pour implémenter l'Autorisation (A01).app/main.py: pour activer les extensions et la configuration de production (A02, A06).
Installation et Lancement
-
Après avoir récupéré le projet, installez les dépendances via Poetry :
poetry install -
A ce stade, si vos imports ne sont pas reconnus dans l'IDE PyCharm à l'ouverture d'un fichier (par exemple
app/main.py), marquer le répertoiresrccommeSources Root(Clic droit sur répertoiresrc, puisMark Directory as ... > Sources Root) ou redémarrer l'IDE (File > Invalidate Caches... > Just restart à gauche). -
Pour démarrer le serveur GraphQL, utilisez la commande suivante à la racine du projet :
# En étant à la racine du projet cd src uvicorn app.main:app --host 0.0.0.0 --port 8002 -
Ouvrez un client GraphQL comme Apollo Studio Sandbox : https://studio.apollographql.com/sandbox/explorer/
-
Renseignez dans l'outil l'URL de votre serveur local :
http://127.0.0.1:8002/graphql
À noter que vous pouvez aussi utiliser l'interface incluse GraphiQL en vous rendant à l'adresse : http://127.0.0.1:8002/graphql
Partie 1 : Authentification (AuthN)
L'API est actuellement totalement publique. Nous allons simuler une authentification par "Bearer Token".
Tâche 1.1 : Injecter l'utilisateur dans le contexte
Dans app/graphql/context.py, modifiez la fonction get_context (actuellement basique) :
- Lisez l'en-tête
Authorizationde la requête (request.headers.get("Authorization")). - Simulez une validation de token (utilisez
fake_db_repopour récupérer les utilisateurs) :- Si le token est
Bearer admin-token, récupérez l'utilisateuradmin(ID 3). - Si le token est
Bearer alice-token, récupérez l'utilisateuralice(ID 1). - Si le token est
Bearer bob-token, récupérez l'utilisateurbob(ID 2). - Sinon, l'utilisateur est
None.
- Si le token est
- Injectez l'utilisateur trouvé dans le contexte sous la clé
current_user.
Voici le flux logique que vous devez implémenter dans get_context :
graph TD
A[début get_context] --> B{lire header 'authorization'};
B --> C{header présent et correct?};
C -- non --> D[current_user = none];
C -- oui --> E{token?};
E -- "bearer admin-token" --> F["user = admin (id 3)"];
E -- "bearer alice-token" --> G["user = alice (id 1)"];
E -- "bearer bob-token" --> H["user = bob (id 2)"];
E -- autre --> D;
F --> I[current_user = user];
G --> I;
H --> I;
I --> J[retourner contexte];
D --> J;
Partie 2 : A01 - Broken Access Control (Autorisation)
Maintenant que nous savons qui est l'utilisateur (grâce au contexte), nous devons vérifier ce qu'il a le droit de faire.
Tâche 2.1 : activer l'extension de gestion d'erreurs
Pour que les exceptions AuthNException et AuthZException que vous allez lever soient correctement formatées pour le client, vous devez activer l'extension fournie.
- Dans
app/main.py, importezSecurityErrorExtensiondepuisapp.graphql.extensions. - Lors de la création de
strawberry.Schema, passez-la dans la listeextensions.
# app/main.py
# ... imports
from app.graphql.extensions import SecurityErrorExtension
# ...
# Crée le schéma GraphQL
extensions=[
SecurityErrorExtension, # Notre extension de gestion d'erreurs
]
if IS_PRODUCTION:
# ... (sera fait en Partie 3)
pass
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
extensions=extensions,
)
# ...
Tâche 2.2 : sécuriser les Resolvers "Root"
Voici le flux de décision typique pour un resolver "root" sécurisé (comme get_note_by_id) :
graph TD
A["début resolver (ex: get_note_by_id)"] --> B["appel get_current_user_from_info(info)"];
B --> C{utilisateur authentifié?};
C -- non --> D[lever AuthNException];
C -- "oui (utilisateur = 'user')" --> E["récupérer la ressource (ex: note)"];
E --> F{"contrôle d'accès (user.is_admin ou user.is_owner)?"};
F -- non --> G[lever AuthZException];
F -- oui --> H["retourner la ressource (note)"];
D --> I["fin (erreur)"];
G --> I;
H --> J["fin (succès)"];
Modifiez les resolvers dans app/graphql/resolvers/note_resolver.py et user_resolver.py. Ils sont actuellement totalement insécurisés.
Pour chaque resolver sensible :
- Récupérez l'utilisateur connecté en utilisant le helper
get_current_user_from_info(info)(fourni dansauth_resolver.py). S'il n'est pas connecté, ce helper lèvera automatiquement uneAuthNException. - Implémentez la logique de contrôle d'accès. Si l'accès est refusé, levez une
AuthZException(fournie danscore.exceptions).
À sécuriser :
get_user_by_id(user_resolver.py) :- Règle : un utilisateur ne peut voir qu'un profil s'il est Admin OU s'il demande son propre profil (
is_self).
- Règle : un utilisateur ne peut voir qu'un profil s'il est Admin OU s'il demande son propre profil (
get_note_by_id(note_resolver.py) :- Règle : un utilisateur ne peut voir une note que s'il est Admin OU s'il est le propriétaire de la note (
is_owner).
- Règle : un utilisateur ne peut voir une note que s'il est Admin OU s'il est le propriétaire de la note (
search_notes(note_resolver.py) :- Problème : la recherche retourne toutes les notes (ex:
contentContains: "secret"), y compris celles des autres. - Correctif : après avoir récupéré la liste de toutes les notes correspondantes, filtrez cette liste en Python pour ne retourner que les notes que l'utilisateur actuel a le droit de voir (Admin ou propriétaire).
- Problème : la recherche retourne toutes les notes (ex:
Tâche 2.3 : sécuriser les resolvers imbriqués
Les failles se cachent souvent dans les champs imbriqués.
Voici le flux de décision pour un resolver imbriqué (comme User.notes) qui ne doit pas lever d'erreur pour ne pas casser la requête parente :
graph TD
A["resolver parent (ex: user(id: "1")) retourne un 'user'"] --> B[graphql engine demande le champ 'notes'];
B --> C["appel resolver 'get_notes_for_user_resolver(root, info)'"];
C --> D["récupérer 'current_user' (de info.context)"];
D --> E["récupérer 'parent_user' (de root)"];
E --> F{"check droits (current_user.is_admin ou current_user == parent_user)?"};
F -- non --> G["retourner liste vide []"];
F -- oui --> H[récupérer et retourner les notes du parent_user];
G --> I[fin];
H --> I[fin];
User.notes(Resolverget_notes_for_user_resolverdansnote_resolver.py) :- Problème : ce resolver est lié au champ
notesdu typeUser. Il doit être sécurisé. - Règle : l'utilisateur connecté (
info.context) ne peut voir les notes de l'utilisateur parent (root) que s'il est Admin ouis_self. - Important : s'il n'a pas le droit, retournez une liste vide
[](ne levez pas d'exception pour ne pas faire échouer la requête parente).
- Problème : ce resolver est lié au champ
Note.owner(Resolverget_note_owner_resolverdansuser_resolver.py) :- Problème : ce resolver retourne un type
User, qui contient un champ sensible (email). - Correctif (Défense en profondeur) : modifiez ce resolver pour qu'il retourne un
UserModel, mais en rédigeant (masquant) les champs sensibles que l'utilisateur n'est pas censé voir dans ce contexte (ex:email="[Redacted]").
- Problème : ce resolver retourne un type
Partie 3 : A02 & A06 - Configuration et design
Protéger l'API contre les abus et la découverte en modifiant app/main.py.
Voici le flux logique de configuration que vous allez implémenter dans main.py :
graph TD
A[démarrage app/main.py] --> B["extensions_base = [securityerrorextension]"];
B --> C{is_production est true?};
C -- non --> D[graphiql = true];
D --> H["créer schema(extensions=extensions_base)"];
C -- oui --> E[graphiql = false];
E --> F["extensions_prod = [noschemainrospection, querydepthlimiter, ...]"];
F --> G[extensions_totales = extensions_base + extensions_prod];
G --> I["créer schema(extensions=extensions_totales)"];
Tâche 3.1 : A02 - Désactiver GraphiQL en production
- Problème : l'interface interactive GraphiQL ne doit jamais être exposée en production.
- Correctif : dans
main.py, utilisez la variablesettings.ENVIRONMENT. Passez l'argumentgraphiql=not IS_PRODUCTIONauGraphQLRouter.
Tâche 3.2 : A06 - Bloquer l'introspection en production
- Problème : l'introspection permet à un attaquant de télécharger votre schéma complet.
- Correctif : dans
main.py:- Importez
NoSchemaIntrospectionCustomRuledepuisgraphql.validation. - Importez
AddValidationRulesdestrawberry.extensions. - Dans le bloc
if IS_PRODUCTION:, créez une listeproduction_extensionset ajoutez-yAddValidationRules([NoSchemaIntrospectionCustomRule]). - Ajoutez cette liste aux extensions principales (
extensions.extend(production_extensions)).
- Importez
Tâche 3.3 : A06 - Prévention DoS (limitation de requêtes)
- Problème : des requêtes GraphQL trop complexes (trop profondes, trop d'alias) peuvent saturer votre serveur (DoS).
- Correctif : dans
main.py, ajoutez les extensions suivantes à votreproduction_extensions(Tâche 3.2) :QueryDepthLimiter(max_depth=7)MaxAliasesLimiter(max_alias_count=15)MaxTokensLimiter(max_token_count=1000)(N'oubliez pas de les importer depuisstrawberry.extensions)
Partie 4 : A05 - Injection (discussion)
- Contexte : le resolver
search_notesprend une entrée utilisateur (contentContains). - Question : notre
fake_db_repo.pyest sécurisé car il effectue une recherche en Python (term in note.content.lower()). - Discussion : si
fake_db_repo.pyutilisait une base de données SQL, quelle serait la faille de sécurité ici ? Comment la fonctionsearch_notes_by_contentdevrait-elle être écrite pour être sécurisée ? (Mots-clés : ORM, requêtes paramétrées).
Partie 5 : vérifier votre travail
Une fois que vous avez implémenté toutes les corrections, utilisez les requêtes ci-dessous pour valider que vos mesures de sécurité sont efficaces.
Préparation des Tests
Avant tout, dans votre config.py, assurez-vous de mettre :
ENVIRONMENT="production"
Puis de redémarrer votre serveur.
Par ailleurs, pour tester l'authentification, vous devez passer le "token" dans les en-têtes (Headers) de votre client GraphQL (Apollo Sandbox, GraphiQL, etc.).
En-tête (Header) à ajouter :
- Clé :
Authorization - Valeur :
Bearer <votre-token>(ex:Bearer alice-tokenouBearer admin-token)
Test 1 : Authentification (AuthN)
Objectif : vérifier qu'une ressource protégée nécessite une authentification.
- Token : Aucun (N'ajoutez pas l'en-tête
Authorization). - Requête :
query GetMyNotes { myNotes { id content } } - Résultat attendu : une erreur. Le message doit indiquer que l'authentification est requise (
AuthNException).
Test 2 : Accès autorisé (Cas nominal)
Objectif : vérifier qu'un utilisateur authentifié peut accéder à ses propres ressources.
- Token :
Bearer alice-token - Requête :
query GetMyData { myNotes { id content } user(id: "1") { # ID d'Alice id username email # Doit être visible car c'est son propre profil } } - Résultat attendu : succès. La requête retourne les notes d'Alice et son profil (y compris l'email).
Test 3 : A01 - Contrôle d'accès (Utilisateur)
Objectif : vérifier qu'un utilisateur ne peut pas voir le profil détaillé d'un autre utilisateur.
- Token :
Bearer alice-token - Requête :
query GetBobProfile { user(id: "2") { # ID de Bob id username email } } - Résultat attendu : une erreur. Le message doit indiquer un refus d'accès (
AuthZException, "Access denied").
Test 4 : A01 - Contrôle d'accès (Ressource)
Objectif : vérifier qu'un utilisateur ne peut pas accéder à la note privée d'un autre.
- Token :
Bearer alice-token - Requête :
query GetBobNote { note(id: "102") { # ID de la note de Bob id content } } - Résultat attendu : une erreur. Le message doit indiquer un refus d'accès (
AuthZException, "Access denied").
Test 5 : A01 - Accès Administrateur
Objectif : vérifier que l'administrateur peut outrepasser les règles précédentes.
- Token :
Bearer admin-token - Requête :
query AdminViewAll { user(id: "1") { # Profil d'Alice id username email } note(id: "102") { # Note de Bob id content } } - Résultat attendu : succès. L'admin voit toutes les données.
Test 6 : A01 - Filtrage de Recherche
Objectif : vérifier que le resolver searchNotes filtre les résultats. La note 102 "Secret de Bob" ne doit être visible que par Bob ou un admin.
- Token :
Bearer alice-token - Requête :
query SearchForSecret { searchNotes(contentContains: "Secret") { id content } } - Résultat attendu : succès.
searchNotesdoit retourner une liste vide[]. Alice ne doit pas voir la note de Bob.
Relancez ce test avec le token Bearer admin-token (doit retourner la note 102) et Bearer bob-token (doit retourner la note 102).
Test 7 : A01 - Sécurité des champs imbriqués (Note.owner)
Objectif : vérifier que le champ email est masqué ([Redacted]) lorsqu'on accède à Note.owner, comme implémenté dans get_note_owner_resolver.
- Token :
Bearer alice-token - Requête :
query NoteOwnerRedacted { myNotes { id content owner { id username email # Doit être masqué } } } - Résultat attendu : succès. Le champ
emaildansownerdoit avoir la valeur"[Redacted]".
Test 8 : A01 - Sécurité des champs imbriqués (User.notes)
Objectif : vérifier que le champ User.notes est filtré (retourne []) si un utilisateur non-admin tente d'y accéder (ce test ne fonctionnera que si vous avez "oublié" de sécuriser Tâche 2.2 get_user_by_id, mais que vous avez sécurisé Tâche 2.3 get_notes_for_user_resolver).
Un test plus pertinent est de vérifier que l'admin voit les notes imbriquées.
- Token :
Bearer admin-token - Requête :
query AdminViewUserNotes { user(id: "1") { # Profil d'Alice id username notes { # Doit montrer les notes d'Alice id content } } } - Résultat attendu : succès. L'admin voit le profil d'Alice et ses notes associées.
Test 9 : A02 & A06 - Configuration Production
Objectif : vérifier que l'introspection et GraphiQL sont désactivés en production.
- Test A (GraphiQL) : essayez d'accéder à
http://127.0.0.1:8002/graphqldans votre navigateur.- Résultat attendu : vous devriez voir
{"detail":"Not Found"}. L'interface GraphiQL ne doit pas se charger.
- Résultat attendu : vous devriez voir
- Test B (Introspection) : allez dans Apollo Sandbox (qui fonctionne toujours) et exécutez la requête d'introspection.
- Requête :
query IntrospectionQuery { __schema { types { name } } } - Résultat attendu : une erreur claire indiquant que l'introspection est désactivée (ex: "GraphQL introspection is not allowed...").
- Requête :
Test 10 : A06 - Limite de profondeur (DoS)
Objectif : vérifier que le QueryDepthLimiter (configuré à max_depth=7 en production) bloque les requêtes excessivement imbriquées.
- Token :
Bearer admin-token(pour éviter toute erreur d'autorisation qui masquerait le test de profondeur). - Requête : (Cette requête a 10 niveaux d'imbrication)
query DeepQueryTest { user(id: "1") { # Niveau 1 notes { # Niveau 2 owner { # Niveau 3 notes { # Niveau 4 owner { # Niveau 5 notes { # Niveau 6 owner { # Niveau 7 notes { # Niveau 8 owner { # Niveau 9 username # Niveau 10 } } } } } } } } } } - Résultat attendu : Une erreur. Le message doit indiquer que la requête est trop profonde ("Query is too deep" ou similaire), prouvant que l'extension
QueryDepthLimiterfonctionne.