
$ cat cve-2026-61726-tandoor-recipes.md
CVE-2026-61726 — Private Recipe Bypass via Step API
The Issue
Tandoor Recipes enforces private recipe authorization on the direct recipe endpoint, but the same private content can be read and modified through the Step API.
In a shared Space, User B cannot access User A’s private recipe via GET /api/recipe/{id}/ — but can still read its steps via GET /api/step/?recipe={id} and modify them via PUT /api/step/{step_id}/. This bypasses the privacy boundary implemented by RecipeViewSet / CustomRecipePermission.
This is a variant of GHSA-v8x3-w674-55p5 / CVE-2026-35045, affecting the Step sub-resource API rather than PUT /api/recipe/batch_update/ — an incomplete fix.
CVE-2026-61726 | High 8.1 | CWE-639 / CWE-863 / CWE-284
Root Cause
RecipeViewSet applies CustomRecipePermission, which enforces private ownership/sharing:
class CustomRecipePermission(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if obj.private:
return ((obj.created_by == request.user) or (request.user in obj.shared.all())) and obj.space == request.space
...
StepViewSet instead uses CustomIsUser, which only checks the user’s role and has no has_object_permission():
class StepViewSet(LoggingMixin, viewsets.ModelViewSet):
queryset = Step.objects
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
def get_queryset(self):
...
return self.queryset.filter(recipe__space=self.request.space)
The queryset filters only by recipe__space. It never applies recipe.private == false, recipe.created_by == user, or user in recipe.shared. IngredientViewSet has the same class of issue, scoping only by Space.
Proof of Concept
Two normal users in the same Space: poc_a (private recipe owner) and poc_b (attacker). poc_a creates a private recipe whose step contains a secret marker.
# Control: intended privacy boundary
GET /api/recipe/5/ by User B -> 403
# Bypass: read + write through the Step API
GET /api/step/?recipe=5 by User B -> 200 (contains the secret marker)
PUT /api/step/5/ by User B -> 200 (instruction edited)
The 403 on the recipe endpoint proves the content is meant to be protected; the two 200 responses prove the bypass through the Step sub-resource.
Impact
- Confidentiality: a same-Space user can read another user’s private recipe instructions, which may contain personal notes, diet/health details, or private prep content.
- Integrity: a same-Space user can silently modify another member’s private recipe.
- Scope: limited to members of the same Space — not a cross-Space tenant escape, but a real cross-user privacy boundary violation.
Remediation
Apply the same recipe access predicate used by RecipeViewSet to all recipe sub-resources (Step and Ingredient):
allowed_recipes = Recipe.objects.filter(space=request.space).filter(
Q(private=False) |
(Q(private=True) & (Q(created_by=request.user) | Q(shared=request.user)))
)
queryset = Step.objects.filter(recipe__in=allowed_recipes)
Also add an object-level has_object_permission() for detail routes. Regression tests should assert User B gets 403/404 for both the recipe endpoint and the Step list/PUT routes.