495 lines
19 KiB
Markdown
495 lines
19 KiB
Markdown
# 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`.
|
|
|
|
<!-- end list -->
|
|
|
|
```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 <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 :**
|
|
```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. |