--- 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:`. 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 `` 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** (`/.`) 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?