docs/threat-model.md
Rama main · réplica de lectura del repositorio original.
---
type: threat-model
project: DeskcommCRM
status: draft
last_updated: 2026-07-29
generated_by: auditoria documental (Claude Code) — leitura de rotas, guards, proxy.ts e lib/env.ts
confidence: média-alta (superfície e guards são CONFIRMADO por leitura de código; explorabilidade é INFERIDO — nada foi testado contra instância viva)
audited_against: origin/main @ 789dfa6 (v1.0.0, 2026-07-27)
---
# Threat model — DeskcommCRM self-host
Complementa [`SECURITY.md`](../SECURITY.md), que é política de *reporte*. Este documento é
o inventário da **superfície de ataque real**: o que fica exposto quando alguém sobe o
DeskcommCRM numa VPS com IP público.
**Modelo de implantação que muda tudo:** o produto é self-host open-source. O atacante
tem o **código-fonte completo** — conhece cada rota, cada nome de env var, cada fallback.
Segurança por obscuridade vale zero aqui. E o operador é tipicamente uma PME sem equipe
de segurança: um default inseguro não vai ser corrigido por ele.
**Nada aqui foi explorado.** Nenhum ataque foi executado, nenhuma instância foi tocada.
São conclusões de leitura de código.
---
## 1. Superfície exposta sem sessão
`lib/auth/public-paths.ts` define o que passa sem checagem de auth no `proxy.ts`
(middleware do Next 16). CONFIRMADO:
| Path | Guard próprio dentro da rota | Rate limit |
|---|---|---|
| `/`, `/login`, `/signup`, `/auth/confirm` | Supabase Auth | ❌ |
| `/team/accept-invite/:token` | HMAC-SHA256 + `timingSafeEqual` (`lib/auth/invite-token.ts`) | ❌ |
| `/api/v1/health` | nenhum (por design) | ❌ |
| `/api/v1/webhooks/waha/:token` | HMAC-SHA512 + `timingSafeEqual` (`lib/waha/ingest.ts`) | ❌ |
| `/api/v1/webhooks/waha` (global, sem token) | só rede interna: requisição com marca de proxy de borda recebe 404 (`chegouPelaBorda`, `lib/http/ip-do-cliente.ts` — régua de cabeçalho, válida nos proxies do kit: Caddy, Traefik, Nginx Proxy Manager, túnel da Cloudflare); depois, o mesmo HMAC. Com a porta do app publicada direto, a camada que vale é a assinatura do remetente (`/admin/sistema`) | ❌ |
| `/api/v1/webhooks/in/:token` | path token + assinatura opcional | ✅ 60/min por token |
| `/api/v1/webhooks/nuvemshop/*` | HMAC | ❌ |
| `/api/v1/webhooks/cobranca/:provedor` | Stripe: `Stripe-Signature` (HMAC com horário). Asaas: token estático em `asaas-access-token` (sha256 + `timingSafeEqual`); o corpo é só ponteiro e a decisão vem da releitura. Token vazado: o dono gera outro em Admin › Cobrança › Conexão, "Testar e conectar" de novo | ✅ recusas 120/min por IP; Asaas, avisos válidos sem empresa identificada (cliente desconhecido ou leitura que falhou) 120/min por provedor, chave global (acima, 200 sem gravar) |
| `/api/v1/cron/*` (9 rotas) | `Bearer INTERNAL_CRON_SECRET\|INTERNAL_SECRET`, **fail-closed** | ❌ |
| `/api/mcp` | `Bearer tok_...` validado contra `api_tokens` (hash SHA256) | ❌ |
| `/api/v1/mcp-oauth/iniciar/:state` e `/api/v1/mcp-oauth/callback/:servidor` (OAuth por contato, 9095) | o `state` de `mcp_oauth_pedidos`: 256 bits, uso único (consumido por UPDATE condicional ANTES da troca do `code`), 10 min, amarrado a organização, servidor, contato e conversa; o `code` só vira token com o `code_verifier` cifrado daquela linha (PKCE S256). O endereço de retorno é um por servidor e tem de ser o do pedido; o `iss` (RFC 9207), quando vem, tem de ser o emissor dele. Nada da URL escolhe tenant | ✅ 30/min por IP |
| `/403`, `/404`, `/500`, `/503`, `/admin/forbidden` | — | ❌ |
**Leitura:** a autenticação de cada superfície está bem construída — HMAC com
`timingSafeEqual` em 6 módulos distintos, crons fail-closed, bearer só via header
(nunca query string), plaintext do token nunca persistido. O problema **não é o guard;
é a ausência de limite de tentativas na frente dele.**
### 1.1 O proxy da borda também é superfície (e o túnel, uma borda terceirizada)
No stack padrão, o `Caddyfile` responde **403** a `/api/v1/webhooks/waha` — o webhook
global, sem token na URL — antes de chegar ao app. O `docker-compose.traefik.yml`
reproduz esse bloqueio; o `docker-compose.npm.yml` deixa o roteamento para o painel do
NPM, e ali o bloqueio é responsabilidade do operador.
O `docker-compose.cloudflared.yml` (instalação atrás de NAT, `REVERSE_PROXY=cloudflared`)
**não tem como impor o 403 pelo compose**: o roteamento é administrado à distância, no
painel da Cloudflare. Quem recusa é o próprio app: a rota global responde 404 ao que
chega com marca de proxy de borda, e o túnel marca com `cf-connecting-ip` (ver a linha da
rota na tabela acima). A assinatura do remetente é opcional e desligada de fábrica
(`/admin/sistema`); uma regra de WAF no painel é camada extra, opcional.
Fora isso, o túnel **reduz** a superfície clássica: não publica porta de entrada nenhuma
(nada de 80/443 escutando), então a varredura de porta e o `network_mode: host` que este
documento assume como premissa não existem nesse modo. O token do túnel é credencial de
saída; vazá-lo permite publicar rotas no túnel da conta, e por isso ele nunca aparece em
log nem em query string.
---
## 2. Riscos por ordem de exploração
### T1 — Brute force e enumeração sem custo 🔴 CONFIRMADO (ausência), INFERIDO (impacto)
> ## ⚠️ ENDEREÇADO DEPOIS DESTA AUDITORIA — releia antes de agir sobre T1
>
> Este documento foi auditado contra `789dfa6` (2026-07-27), e o front-matter diz isso. O
> corpo, porém, fala no **presente** — e um leitor decide pelo corpo. Medido em 2026-08-14 na
> `main`:
>
> - **`lib/auth/rate-limit.ts` existe** e foi escrito para fechar exatamente este risco: o
> cabeçalho cita a issue #64 e nomeia login, signup, recuperação de senha e aceite de convite.
> - É usado nos **quatro** pontos que o texto abaixo diz estarem descobertos:
> `app/actions/auth/signInWithPassword.ts`, `signUp.ts`, `requestPasswordReset.ts` e
> `app/team/accept-invite/[token]/page.tsx`.
> - Limites em vigor (`AUTH_LIMITS`): `login` 5 por identificador / 300 s, `signup` 20 por IP /
> 3600 s, `reset` 30 IP e 3 id / 3600 s, `invite_accept` 60 por IP / 3600 s.
> - **A sub-afirmação "não existe lockout por conta" sobrevive à letra e morre no espírito.** A
> sonda que o texto usa (`grep lockout|failed_attempts`) devolve **zero** — ainda hoje. Mas
> `rate-limit.ts:158/165` conta falha de login **por conta**, na chave
> `auth:login_fail:id:<hash>`. A sonda é cega para o mecanismo que existe, e é o exemplo mais
> limpo deste documento de por que ausência de sonda não é ausência de defesa.
>
> **O veredito 🔴 NÃO foi rebaixado aqui, de propósito.** Rebaixar risco de segurança pede
> reauditoria com teste contra instância viva, que é o que o próprio `confidence` do
> front-matter diz nunca ter havido. O que esta nota faz é corrigir os **fatos** e marcar o T1
> como devendo reauditoria — não decidir por ela.
`checkRateLimit` existe (`lib/ai/dispatcher/rate-limit.ts`) e é chamado em vários pontos de
`app/` e `lib/`, o webhook `/api/v1/webhooks/in/:token` entre eles. Reconte, não cite: `grep -rln 'checkRateLimit(' app lib --include='*.ts' | grep -v '\.test\.'`.
Não há **nenhum** limite de tentativa em:
- **`/login`** — e não existe lockout por conta: `grep` por `lockout` / `failed_attempts`
não retorna nada. Senha fraca de operador é atacável na velocidade da rede.
- **`/signup`** — criação de organização em massa; num self-host multi-tenant isso é
exaustão de recurso (e de cota de IA, se as chaves forem da instância).
- **`/team/accept-invite/:token`** — o HMAC é forte, mas sem limite o atacante pode
sondar indefinidamente e sem custo, e sem gerar sinal de alerta.
- **Os 9 crons** — o secret é forte e a comparação é em tempo
constante, mas nada limita o volume de tentativas.
- **`/api/mcp`** — enumeração de bearer token.
**Mitigação recomendada:** aplicar `checkRateLimit` por prefixo no `proxy.ts` (uma passada
cobre todo o surface público de uma vez) + limite por identificador nas rotas de auth
(por e-mail no login, não só por IP — IP rotativo é trivial). Custo baixo, a infra já existe.
### T2 — Fallback in-memory do rate limit anula o limite que existe 🟠 CONFIRMADO
`lib/ai/dispatcher/rate-limit.ts:23` — sem `UPSTASH_REDIS_REST_URL`/`_TOKEN` o contador cai
para um `Map` em memória do processo. Consequências:
- É o estado **normal de um primeiro deploy** (Upstash é serviço externo; `lib/env.ts:69`
trata as duas vars como opcionais em dev e obrigatórias só em produção).
- Multi-instância ou multi-worker ⇒ limite por processo, não por tenant.
- O único sinal é um `logger.warn` uma vez por processo. Um operador de PME não vai ver.
O stack de produção do kit inclui `serverless-redis-http` + Redis local (visto em
`docker-compose.prod.yml`), o que resolve — **A CONFIRMAR** se o `install.sh` garante que
essas duas vars ficam populadas em toda instalação.
### T3 — 89 handlers com service role, sem gate de escrita 🟠 CONFIRMADO (contagem)
`createAdminClient` (service role, **bypassa RLS**) é importado em **89 dos 169** route
handlers de `app/api/**` (dos quais 166 estão sob `/api/v1/`). A regra da doutrina —
"filtre `organization_id` manualmente, resolvido de fonte
confiável, nunca do body" — é aplicada por revisão humana. Não há lint rule nem teste que
falhe quando um handler *novo* esquece o filtro.
Este é o **pior modo de falha do produto**: vazamento cross-tenant. Duas mitigações reais
existem: as amostras que li (`admin/tenants`, `webhooks/in/:token`, `team/:user_id`) seguem o
padrão corretamente, e os **56 arquivos de invariante em `tests/invariants/` rodam no CI**
(job `invariants` → `pnpm test:db`), cobrindo isolamento cross-tenant de verdade. O
guard-rail existe **e está ligado** — rebaixei de 🔴 para 🟠 por isso.
**Lacuna residual:** os invariantes provam que os caminhos cobertos isolam; não impedem que
um handler novo nasça sem filtro e sem invariante correspondente.
**Mitigação recomendada:** regra de ESLint custom (ou teste que varre o diff) que falhe
quando um arquivo importa `lib/supabase/admin` sem referenciar `organization_id`. Barato,
determinístico, e transforma disciplina em gate.
### T4 — Secret de convite com fallback conhecido 🟠 CONFIRMADO no código, mitigado na prática
`lib/auth/invite-token.ts:16`:
```
INVITE_TOKEN_SECRET → INTERNAL_SECRET → "dev-fallback"
```
Se a cadeia chegar em `"dev-fallback"`, qualquer pessoa com o repo público forja um token
de convite válido — payload inclui `organization_id` e `role`, ou seja: **admin em qualquer
org**.
**Mitigação existente:** `INTERNAL_SECRET` é `required()` em `lib/env.ts:47`, que em
`NODE_ENV=production` **derruba o boot** se estiver vazio. Então numa instância de produção
que subiu, o fallback é inalcançável (INFERIDO — depende de o self-host rodar com
`NODE_ENV=production`, o que é o esperado com `next start`).
**Residual:** (a) em dev a validação afrouxa e a var vira `""` — e string vazia é falsy,
então cai no `"dev-fallback"`; (b) `invite-token.ts` lê `process.env` **cru**, contornando
o Zod, então não herda garantia nenhuma; (c) `INVITE_TOKEN_SECRET` não existe em
`lib/env.ts` nem em `.env.example`, só em docs de épico.
Já está rastreado pelo projeto como risco **M4** em `docs/testing/user-journey-map.md` —
crédito onde é devido.
**Mitigação recomendada:** eliminar o literal. Falhar alto (`throw`) quando nenhum secret
existe, em vez de degradar para um valor público.
### T5 — Secrets ausentes do `.env.example` 🟠 CONFIRMADO
`IMPERSONATE_COOKIE_SECRET`, `INTERNAL_CRON_SECRET`, `LGPD_SIGNING_KEY` estão em
`lib/env.ts` e **não** no template. O operador não sabe que precisa gerá-los.
Como são `required()` (obrigatórios só em produção), o efeito mais provável é falha de boot
com mensagem clara — que é o comportamento seguro. O risco real é indireto: instalação
frustrada, operador colando valor fraco ("123456") só para o app subir, ou reaproveitando
o mesmo valor em todos.
### T6 — SSRF em webhook de saída 🟢 MITIGADO — CONFIRMADO
`lib/automation/outbound-url.ts` + `outbound-url.test.ts` + `lib/automation/actions/call-webhook.ts`
implementam guard de URL de saída, e existe E2E dedicado
(`tests/e2e/vps-webhook-outbound-ssrf.spec.ts`). É a defesa mais bem feita do repo.
**Ressalva:** esse E2E **não roda no CI** — e é a única prova automatizada do guard.
`outbound-url.test.ts` é unitário e roda, o que cobre a lógica de decisão; o que não roda é a
prova de que o egress real está barrado ponta a ponta. Uma regressão na integração passa.
**Segundo egress do servidor (branch das extensões declarativas):** `lib/extensions/download.ts`
baixa o pacote de uma origem de catálogo que o dono da instalação admitiu. Guarda própria, não a
de webhook: só HTTPS, sem redirect, sem cookie nem credencial, DNS resolvido uma vez e o endereço
amarrado à conexão (sem janela de rebinding), endereços especiais IPv4/IPv6 recusados, teto de
bytes contado no corpo lido e prazo total de 15 s. A exceção HTTP em loopback
(`EXTENSIONS_LOCAL_CATALOG_ORIGIN`) só vale com o app também em loopback. Para ver o que roda:
`pnpm exec vitest run lib/extensions/download.test.ts lib/extensions/download-rebinding.test.ts`.
**Ressalva do mesmo tipo:** o caminho HTTPS real (SNI, certificado, lookup amarrado) não é
exercitado por nenhum gate — os testes usam HTTP local e um dublê do lookup.
### T7 — Sem varredura de secret no histórico git 🟡 CONFIRMADO
Sem gitleaks/trufflehog no CI, sem pre-commit hook (`.husky` e `.pre-commit-config.yaml`
ausentes). `.gitignore` cobre `.env*` corretamente, e essa é a única camada.
Agravante específico deste repo: a doutrina de QA visual **incentiva commitar evidência
visual**, e há **116 PNGs rastreados** (85 em `evidence/` contando subpastas, 18 em
`docs/evidence/`, 13 em `loop/checkpoints/evidence/`). Screenshot de tela autenticada pode conter telefone, nome de
cliente ou token em URL — e várias evidências são explicitamente descritas nos HANDOFFs como
tiradas em **conta e conversa reais de WhatsApp**. Num repo público, é irreversível.
Não é argumento contra a doutrina de evidência visual, que é boa. É argumento para um passo
de revisão de PII antes do commit — e `gitleaks` não pega isso, porque não lê imagem.
### T8 — Onde a auditoria é cega ⚪ NÃO IDENTIFICADO
Não avaliado por falta de execução/instância:
- Se as políticas RLS **realmente** isolam (os invariantes existem para provar; não foram rodados).
- Postura do container WAHA — `docker-compose.prod.yml` comenta "Core por default, dashboard
off", mas exposição de porta e rede não foram verificadas contra instância viva.
- Config do Caddy (`Caddyfile`) — TLS, headers de segurança, HSTS.
- Se `next.config.ts` define CSP / security headers.
- Storage: se o bucket `whatsapp-media` está privado de fato e se a expiração das signed
URLs é adequada.
- Storage, e este é MEDIDO e DECLARADO em vez de "não avaliado": `brand-logos` (migration
0158) é o **único bucket público** do repositório — todos os outros nascem
`public = false` (inclusive `catalog-photos`, das fotos do catálogo, migration 0390), e
quem conta é `tests/invariants/marca-logo.test.ts`, que reprova um segundo bucket público. A exceção existe porque o logo é renderizado num `<img>` da tela de
**login**, servida a quem não tem sessão, e URL assinada **vence**: a marca da instalação
sumiria da fachada sozinha no dia do vencimento. O que a contém, e o que
`tests/invariants/marca-logo.test.ts` reprova quando deixa de valer: bucket **exclusivo**
de logo (nada de conversa, export ou base de conhecimento mora nele, então "público" não
vaza histórico de cliente), **zero policy** em `storage.objects` citando o bucket
(`public = true` abre a LEITURA pelo endpoint `/object/public/...`; INSERT e DELETE
continuam só no `service_role`), caminho **não-enumerável** (`<prefixo>/<uuid v4>.<ext>`)
e teto de 512 KB. O que entra é decidido pelos **bytes** (`lib/branding/logo-arquivo.ts`),
nunca pelo `Content-Type` — `allowed_mime_types` do bucket e `file.type` na rota leem a
mesma string que quem sobe escolhe. **SVG é banido**: este repo não define CSP (ver o item
acima sobre `next.config.ts`), e SVG navegado direto do endereço da imagem executa script.
O que NÃO está coberto: um logo subido é **público para sempre até ser trocado** — quem
subir por engano uma arte confidencial precisa removê-la pela tela, e a URL antiga deixa
de existir junto com o arquivo (a rota apaga o anterior na troca).
- Escopo do service role key no Supabase e rotação de chaves.
- Efetividade do `beforeSend` do Sentry contra PII real.
---
## 3. Sumário de prioridade
| # | Risco | Sev | Custo do fix |
|---|---|---|---|
| T1 | Sem rate limit em login/signup/convite/crons/MCP | 🔴 | baixo — infra já existe |
| T2 | Rate limit degrada silenciosamente para memória | 🟠 | baixo |
| T3 | Service role sem gate de escrita para handler novo | 🟠 | médio (lint rule) — invariantes já cobrem em CI |
| T4 | `"dev-fallback"` como secret de convite | 🟠 | trivial |
| T5 | 3 secrets fora do `.env.example` | 🟠 | trivial |
| T7 | Sem scan de secret no CI + 116 PNGs de evidência sem revisão de PII | 🟡 | baixo |
| T6 | Guard de SSRF existe; o E2E que o prova não roda no CI | 🟢 | baixo |
## 4.5 Banco externo — receitas prontas (Spec 25, R1)
Superfície nova, toda atrás de sessão + `requireRole("admin")` + `requireSupportWrite()`:
| Path | Guard | Rate limit | O que expõe |
|------|-------|------------|-------------|
| `PUT /api/v1/external-db/connections/:id/recipes` | admin + requireSupportWrite | `external-db:write` (30/60s) | gravar instâncias de receita (audit: só contagens) |
| `POST /api/v1/external-db/connections/:id/recipes/preview` | admin + requireSupportWrite | `external-db:write` (30/60s) | executar uma instância, devolve linhas (audit: só contagens) |
| `crm_consultar_receita` (tool MCP) | agente, `modulo: "banco_externo"` | teto de token do turno | listar/executar instâncias; organização **sempre do `ctx`** |
Controles: a coluna `receitas` fica FORA da view `external_db_connections_safe` (só `receitas_count` exposta); o prefixo das tabelas entra no SQL quotado com crase (nunca cru); todo valor vai por placeholder `?`; o validador `validarConsultaDeReceita` é a segunda trava (SELECT-only, sem `;`/`--`/`INTO`/`SLEEP(`/etc.); o rastro da execução resume `linhas` (nunca o conteúdo — regra de escopo da D8).
A receita `wordpress-conteudo` (R2) pode ler, além de `posts`/`postmeta`, as **tabelas de categoria** do WordPress (`terms`, `term_taxonomy`, `term_relationships`) — e só quando o administrador mapeou alguma categoria. Elas passam pela MESMA lista de fontes liberadas (`tabelaLiberada`/`colunasLiberadas`, em `lib/external-db/fontes.ts`), então o administrador precisa liberá-las como qualquer outra tabela: quem não libera, recebe `fonte_nao_liberada` e a consulta não roda. O conteúdo dos termos é dado de terceiro (o autor do site), e ele chega ao agente como NOME de categoria — o mesmo regime do resto da receita: o rastro guarda contagens, nunca o conteúdo.
**Conclusão honesta:** os *mecanismos* de segurança deste projeto são acima da média para
um CRM open-source — HMAC em tempo constante em toda borda, fail-closed nos crons, hash de
bearer, RLS com helper central, guard de SSRF testado, LGPD implementada de verdade,
`beforeSend` higienizando PII, e **56 arquivos de invariante de isolamento rodando em CI**.
O que falta é estreito e específico: **limite de tentativa na frente dos guards**. Não há
falha de desenho aqui; há uma camada ausente, e ela é a mais barata de todas as que já foram
construídas.
---
## 4.6 Servidores MCP por agente e OAuth por contato (migration 9095)
Superfície nova. Guia: [`integracao/mcp-oauth-por-contato.md`](integracao/mcp-oauth-por-contato.md).
| Superfície | Controle | Teste |
|---|---|---|
| Rotas públicas `iniciar/:state` e `callback` | `state` de uso único, expira em 10 min, amarrado ao contato e à conversa; consumo antes da troca; `error=` do AS só depois de consumir; página de desfecho sem dado sensível, sem script, `no-store` e `no-referrer`; rate limit por IP | `lib/mcp/servidor-externo/do-agente/oauth-por-contato.test.ts` |
| Tokens de terceiro (`contato_mcp_credenciais`), chave e `client_secret` (`ai_agent_mcp_servers`), `code_verifier` (`mcp_oauth_pedidos`) | AES-256-GCM com `AI_CRED_AES_KEY`; tabelas server-side only (RLS sem policy, nenhum GRANT para `anon`/`authenticated`); filtro manual por `organization_id` + `servidor_id` + `contact_id` do turno; token nunca em log, auditoria, `event_log` ou resposta de ferramenta | `tests/invariants/servidores-mcp-por-agente-server-side.test.ts` + marcador de token no teste unitário |
| Saída para o terceiro (MCP, descoberta, DCR, token, revogação) | o mesmo `fetchDeSaida`: prova textual, DNS sem faixa privada, `redirect: "manual"` dentro da allowlist dos hosts descobertos; S256 obrigatório no cadastro | idem |
| Link enviado pelo canal | é o link curto da instalação (não expõe parâmetro do OAuth). **Risco residual aceito:** quem tiver o link nos 10 minutos e o login do terceiro vincula a PRÓPRIA conta àquele contato — o mesmo modelo de qualquer "entrar com" enviado por mensagem. O link é de uso único e só serve ao contato e à conversa que o gerou | — |
| Escrita delegada | desligada por padrão; só `oauth_por_contato`, só ferramenta marcada Escrita pelo admin, só com o token do contato do turno; passa por `wrapMcpTool` (auditoria, papel, escopo) | idem |
**Não feito nesta fatia (declarado):** o callback não reabre o turno; o `state` é guardado em
claro como chave primária (o `code_verifier` é que prova a posse, e está cifrado).
## 4. Perguntas para o responsável
1. O `install.sh` garante `UPSTASH_REDIS_REST_URL`/`_TOKEN` populados em toda instalação?
(decide a severidade de T2)
2. Alguma instância de produção já rodou sem `INTERNAL_SECRET` definido? (decide se T4 já
foi exposto em campo)
3. Os 116 PNGs de evidência foram revisados quanto a PII antes do commit? Vários são
descritos como tirados em conta e conversa reais de WhatsApp.
4. Existe branch protection exigindo os dois checks do CI verdes no merge? (não é visível
no checkout)
5. Há intenção de pedir pentest externo antes de divulgar a v1.0.0 mais amplamente?