# 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/` (contient `config.py` et `exceptions.py` qui définit `AuthNException` et `AuthZException`) * `app/graphql/resolvers/auth_resolver.py` (contient le helper `get_current_user_from_info`) * `app/graphql/resolvers/noop.py` * `app/graphql/types/` (les types `Note` et `User` sont définis) * `app/graphql/queries.py` et `mutations.py` (les points d'entrée sont définis) * `app/graphql/extensions.py` (contient la `SecurityErrorExtension` prête à l'emploi pour formater les erreurs) **Ce sur quoi vous allez travailler (fichiers à modifier) :** 1. `app/graphql/context.py` : pour implémenter l'**Authentification**. 2. `app/graphql/resolvers/note_resolver.py` et `user_resolver.py` : pour implémenter l'**Autorisation (A01)**. 3. `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 : ```bash 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épertoire `src` comme `Sources Root` (Clic droit sur répertoire `src`, puis `Mark 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 : ```bash # 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/](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) : 1. Lisez l'en-tête `Authorization` de la requête (`request.headers.get("Authorization")`). 2. Simulez une validation de token (utilisez `fake_db_repo` pour récupérer les utilisateurs) : * Si le token est `Bearer admin-token`, récupérez l'utilisateur `admin` (ID 3). * Si le token est `Bearer alice-token`, récupérez l'utilisateur `alice` (ID 1). * Si le token est `Bearer bob-token`, récupérez l'utilisateur `bob` (ID 2). * Sinon, l'utilisateur est `None`. 3. Injectez l'utilisateur trouvé dans le contexte sous la clé `current_user`. Voici le flux logique que vous devez implémenter dans `get_context` : ```mermaid 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. 1. Dans `app/main.py`, importez `SecurityErrorExtension` depuis `app.graphql.extensions`. 2. Lors de la création de `strawberry.Schema`, passez-la dans la liste `extensions`. ```python # 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`) : ```mermaid 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 : 1. Récupérez l'utilisateur connecté en utilisant le helper `get_current_user_from_info(info)` (fourni dans `auth_resolver.py`). S'il n'est pas connecté, ce helper lèvera automatiquement une `AuthNException`. 2. Implémentez la logique de contrôle d'accès. Si l'accès est refusé, levez une `AuthZException` (fournie dans `core.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`). * **`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`). * **`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). ### 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 : ```mermaid 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`** (Resolver `get_notes_for_user_resolver` dans `note_resolver.py`) : * *Problème :* ce resolver est lié au champ `notes` du type `User`. 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** ou **`is_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). * **`Note.owner`** (Resolver `get_note_owner_resolver` dans `user_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]"`). ----- ## 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` : ```mermaid 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 variable `settings.ENVIRONMENT`. Passez l'argument `graphiql=not IS_PRODUCTION` au `GraphQLRouter`. ### 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` : 1. Importez `NoSchemaIntrospectionCustomRule` depuis `graphql.validation`. 2. Importez `AddValidationRules` de `strawberry.extensions`. 3. Dans le bloc `if IS_PRODUCTION:`, créez une liste `production_extensions` et ajoutez-y `AddValidationRules([NoSchemaIntrospectionCustomRule])`. 4. Ajoutez cette liste aux extensions principales (`extensions.extend(production_extensions)`). ### 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 à votre `production_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 depuis `strawberry.extensions`)* ----- ## Partie 4 : A05 - Injection (discussion) * *Contexte :* le resolver `search_notes` prend une entrée utilisateur (`contentContains`). * *Question :* notre `fake_db_repo.py` est sécurisé car il effectue une recherche en Python (`term in note.content.lower()`). * *Discussion :* si `fake_db_repo.py` utilisait une base de données SQL, quelle serait la faille de sécurité ici ? Comment la fonction `search_notes_by_content` devrait-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 : ```python 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 ` (ex: `Bearer alice-token` ou `Bearer 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 :** ```graphql 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 :** ```graphql 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 :** ```graphql 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 :** ```graphql 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 :** ```graphql 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 :** ```graphql query SearchForSecret { searchNotes(contentContains: "Secret") { id content } } ``` * **Résultat attendu :** succès. `searchNotes` doit 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 :** ```graphql query NoteOwnerRedacted { myNotes { id content owner { id username email # Doit être masqué } } } ``` * **Résultat attendu :** succès. Le champ `email` dans `owner` doit 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 :** ```graphql 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. 1. **Test A (GraphiQL) :** essayez d'accéder à `http://127.0.0.1:8002/graphql` dans votre navigateur. * **Résultat attendu :** vous devriez voir `{"detail":"Not Found"}`. L'interface GraphiQL ne doit pas se charger. 2. **Test B (Introspection) :** allez dans Apollo Sandbox (qui fonctionne toujours) et exécutez la requête d'introspection. * **Requête :** ```graphql 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..."). ----- ### 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) ```graphql 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 `QueryDepthLimiter` fonctionne.