2025-12-18 14:52:33 +01:00
2025-12-18 14:52:33 +01:00
2025-12-18 14:52:33 +01:00
2025-12-18 14:52:33 +01:00
2025-12-18 14:52:33 +01:00
2025-12-18 14:52:33 +01:00

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 :

    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 :

    # 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) :

  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 :

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.
# 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 :

  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 :

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 :

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 :

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-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 :
    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. 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 :
    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 :
    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 :
      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)
    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.
Description
No description provided
Readme 122 KiB
Languages
Python 100%