Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .tmp-write-probe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// supabase/functions/_shared/dispatcher-auth.ts — sentinel test write
// (will be overwritten below if push works)
Comment on lines +1 to +2
Comment on lines +1 to +2
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remover artefato temporário antes do merge.

Em Line [1] e Line [2] há um probe de escrita temporário. Esse arquivo de diagnóstico não deve entrar no main.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.tmp-write-probe.md around lines 1 - 2, Remova o artefato temporário
".tmp-write-probe.md" que contém a linha de probe
("supabase/functions/_shared/dispatcher-auth.ts — sentinel test write") antes do
merge: delete o arquivo do commit atual (ou reverta o commit que o adicionou) e
force um novo push; opcionalmente adicione uma entrada apropriada ao .gitignore
para evitar re-commits acidentais do probe no futuro.

79 changes: 79 additions & 0 deletions docs/hardening/ONDA-1-EDGE-AUTH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Onda 1 — Hardening de Auth em Edge Functions

**Data:** 2026-05-14
**Branch:** `cleanup/edge-functions-auth-hardening`
**Auditoria base:** `AUDITORIA_REDEPLOY_PROMO_GIFTS_2026-05-13_15-32 (1).md` — Bloqueadores 3.1 e 3.2

## Problema

Duas edge functions estavam expostas publicamente (`verify_jwt = false`) sem nenhuma validação custom de origem, permitindo abuso por qualquer pessoa com a URL:

- **`webhook-dispatcher`**: chamável anonimamente para disparar webhooks, replay de entregas falhas, ou testar webhooks arbitrários. Atacante poderia poluir endpoints externos cadastrados em `outbound_webhooks`.
- **`connections-auto-test`**: chamável anonimamente para forçar testes de conexões com sistemas externos (CRM, Bitrix). Atacante poderia consumir quota de Edge Function, gerar logs falsos, e indiretamente sondar quais credenciais existem.

## Solução implementada

### `webhook-dispatcher` — auth multi-modo

Três contextos de uso, três modos de auth aceitos:

| Caller | Mecanismo | Modo |
|---|---|---|
| Trigger DB `dispatch_quote_webhook_event` | Lê secret do vault, envia `x-dispatcher-secret` | A |
| RPC `retry_failed_webhook_deliveries` | Mesmo | A |
| Frontend admin (`WebhookPlaygroundPanel`, `FailedDeliveriesPanel`) | `Authorization: Bearer <user JWT>` + role ≥ supervisor | B |
| Servidor com service role | `Authorization: Bearer <SERVICE_ROLE_KEY>` (detectado e tratado como Modo A) | A |

**Operações sensíveis** (`test_mode`, `replay_delivery_id`) exigem Modo B obrigatoriamente — Modo A é rejeitado com 403 para evitar abuso por server-side caller que vazasse o secret.

### `connections-auto-test` — auth single-mode

Um caller único (cron `connections-auto-test`), um modo:

- **Modo C**: cron lê secret do vault, envia `x-cron-secret`

### Retrocompat

Se a env var do secret não estiver configurada na edge function (deploy parcial, ambiente de dev), o helper **aceita anônimo com warning log estruturado**. Isto:
- Permite rollback seguro: reverter a env não quebra a função
- Permite ambiente de dev sem dependência de vault
- Loga claramente em produção pra alertar se algo der errado

## Como rotacionar os secrets

1. Gerar novo secret: `SELECT encode(gen_random_bytes(32), 'base64');`
2. Atualizar no painel: Supabase Dashboard → Edge Functions → Secrets (`WEBHOOK_DISPATCHER_SECRET` e/ou `CONNECTIONS_AUTO_TEST_SECRET`)
3. Atualizar no vault: `SELECT vault.update_secret((SELECT id FROM vault.secrets WHERE name = 'WEBHOOK_DISPATCHER_SECRET'), '<NOVO_VALOR>');`
4. Validar: chamar manualmente com novo secret e ver `auto-test-summary` em logs

**Ordem importa**: setar PRIMEIRO no painel (Deno.env) → DEPOIS no vault. Caso contrário há janela onde triggers DB enviam um valor que a edge ainda não conhece.

## Checklist pós-deploy

- [ ] **(automatic)** Cron `connections-auto-test` rodando a cada 15min com 200 OK nos logs
- [ ] **(manual)** Criar/atualizar uma quote via UI, validar que `webhook_deliveries` ganha 1 linha success=true para cada webhook ativo
- [ ] **(manual)** Frontend admin: testar webhook via Playground → deve funcionar (Modo B)
- [ ] **(manual)** Frontend admin: replay de delivery falhada → deve funcionar (Modo B)
- [ ] **(curl)** Chamada anônima sem headers → deve retornar 401:
```bash
curl -i -X POST https://doufsxqlfjyuvxuezpln.supabase.co/functions/v1/webhook-dispatcher \
-H "Content-Type: application/json" -d '{"event":"test","payload":{}}'
```
- [ ] **(curl)** Chamada com secret errado → deve retornar 401
- [ ] **(curl)** Chamada `test_mode: true` com x-dispatcher-secret → deve retornar 403 (exige Modo B)
- [ ] **(logs)** Logs Supabase contêm eventos `dispatcher_auth` com `outcome` correto

## Validação automatizada

- `supabase/functions/_shared/dispatcher-auth.test.ts` — 12 testes unitários cobrindo:
- `constantTimeEqual`: igual, diferente, tamanhos diferentes, vazio, não-string, base64 real
- `authorizeCron`: env não setada (legacy), sem header (401), header correto (ok), header errado (401), tamanho diferente, chars especiais
- `deno check` em todas as 78 edge functions: passou

## Rollback de emergência

Se algo der errado:

1. **Edge function**: re-deploy do commit anterior. A funcionalidade continua porque os triggers/cron/RPCs continuam enviando o header (que será ignorado pela função antiga).
2. **Banco**: as alterações nas funções SQL podem ser revertidas via MCP `apply_migration` com a versão anterior das funções (ver `git log` para diff).
3. **Vault**: secrets podem ser desabilitados via `UPDATE vault.secrets SET ... WHERE name = ...` — mas a edge function entra em modo retrocompat e aceita anônimo, então não há quebra.
5 changes: 5 additions & 0 deletions scripts/check-no-db-push.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ const ALLOWLIST = [
// Auto-referências:
'scripts/check-no-db-push.mjs',
'scripts/gen-migrations-readme.mjs',
// Relatorios de auditoria (referenciam o comando ao descrever o problema):
'AUDITORIA_REDEPLOY_PROMO_GIFTS_2026-05-13_15-32 (1).md',
'docs/AUDITORIA_INDEPENDENTE_PRE_PRODUCAO_2026-05-13.md',
'docs/AUDIT_INDEPENDENTE.md',
'docs/hardening/',
];

function isAllowed(path) {
Expand Down
141 changes: 141 additions & 0 deletions supabase/functions/_shared/dispatcher-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// supabase/functions/_shared/dispatcher-auth.test.ts
// Testes unitários para autorização de webhook-dispatcher e connections-auto-test.

import { assertEquals, assertStrictEquals } from "https://deno.land/std@0.224.0/assert/mod.ts";
import { authorizeCron, constantTimeEqual } from "./dispatcher-auth.ts";

// CORS headers mock simples (não usados nas asserts)
const CORS = { "access-control-allow-origin": "*" };

// ───────────────────────────────────────────────────────────────────────
// constantTimeEqual
// ───────────────────────────────────────────────────────────────────────

Deno.test("constantTimeEqual: strings iguais => true", () => {
assertStrictEquals(constantTimeEqual("abc123", "abc123"), true);
});

Deno.test("constantTimeEqual: strings diferentes => false", () => {
assertStrictEquals(constantTimeEqual("abc123", "abc124"), false);
});

Deno.test("constantTimeEqual: tamanhos diferentes => false (cedo)", () => {
assertStrictEquals(constantTimeEqual("abc", "abcd"), false);
});

Deno.test("constantTimeEqual: string vazia vs string vazia => true", () => {
assertStrictEquals(constantTimeEqual("", ""), true);
});

Deno.test("constantTimeEqual: input nao-string => false", () => {
// @ts-expect-error - testa runtime guard
assertStrictEquals(constantTimeEqual(null, "abc"), false);
// @ts-expect-error
assertStrictEquals(constantTimeEqual("abc", undefined), false);
});

Deno.test("constantTimeEqual: secrets longos do mundo real", () => {
const a = "4aszZ/Nh0cInRX0RVTkt+YGqA8BObghWsoAjEOGB7g8=";
const b = "4aszZ/Nh0cInRX0RVTkt+YGqA8BObghWsoAjEOGB7g8=";
const c = "4aszZ/Nh0cInRX0RVTkt+YGqA8BObghWsoAjEOGB7g9=";
assertStrictEquals(constantTimeEqual(a, b), true);
assertStrictEquals(constantTimeEqual(a, c), false);
});

// ───────────────────────────────────────────────────────────────────────
// authorizeCron — Modo C
// ───────────────────────────────────────────────────────────────────────

function makeRequest(headers: Record<string, string> = {}): Request {
return new Request("https://example.com/", { method: "POST", headers });
}

Deno.test("authorizeCron: env nao setada => legacy_no_auth (retrocompat)", () => {
Deno.env.delete("TEST_CRON_SECRET_1");
const req = makeRequest({});
const result = authorizeCron(req, {
corsHeaders: CORS,
secretEnvName: "TEST_CRON_SECRET_1",
headerName: "x-cron-secret",
});
assertEquals(result.ok, true);
if (result.ok) {
assertEquals(result.mode, "legacy_no_auth");
}
});

Deno.test("authorizeCron: env setada + sem header => 401", () => {
Deno.env.set("TEST_CRON_SECRET_2", "supersecret123");
const req = makeRequest({});
const result = authorizeCron(req, {
corsHeaders: CORS,
secretEnvName: "TEST_CRON_SECRET_2",
headerName: "x-cron-secret",
});
assertEquals(result.ok, false);
if (!result.ok) {
assertEquals(result.response.status, 401);
}
Deno.env.delete("TEST_CRON_SECRET_2");
});

Deno.test("authorizeCron: env setada + header correto => ok (modo secret)", () => {
Deno.env.set("TEST_CRON_SECRET_3", "supersecret123");
const req = makeRequest({ "x-cron-secret": "supersecret123" });
const result = authorizeCron(req, {
corsHeaders: CORS,
secretEnvName: "TEST_CRON_SECRET_3",
headerName: "x-cron-secret",
});
assertEquals(result.ok, true);
if (result.ok) {
assertEquals(result.mode, "secret");
}
Deno.env.delete("TEST_CRON_SECRET_3");
});

Deno.test("authorizeCron: env setada + header errado => 401", () => {
Deno.env.set("TEST_CRON_SECRET_4", "supersecret123");
const req = makeRequest({ "x-cron-secret": "wrong_secret" });
const result = authorizeCron(req, {
corsHeaders: CORS,
secretEnvName: "TEST_CRON_SECRET_4",
headerName: "x-cron-secret",
});
assertEquals(result.ok, false);
if (!result.ok) {
assertEquals(result.response.status, 401);
}
Deno.env.delete("TEST_CRON_SECRET_4");
});

Deno.test("authorizeCron: header de tamanho diferente nao causa timing leak", () => {
// Não é teste de timing real (impreciso em CI), mas valida que NÃO retorna
// sucesso só porque o tamanho difere (a função aborta cedo com return false).
Deno.env.set("TEST_CRON_SECRET_5", "supersecret123");
const req = makeRequest({ "x-cron-secret": "x" });
const result = authorizeCron(req, {
corsHeaders: CORS,
secretEnvName: "TEST_CRON_SECRET_5",
headerName: "x-cron-secret",
});
assertEquals(result.ok, false);
Deno.env.delete("TEST_CRON_SECRET_5");
});

Deno.test("authorizeCron: secret com chars especiais (base64)", () => {
const realSecret = "j/nKCXCqyvYgucMAX1wuHJO6QhEDPVaWLWoIsqlfp+o=";
Deno.env.set("TEST_CRON_SECRET_6", realSecret);
const req = makeRequest({ "x-cron-secret": realSecret });
const result = authorizeCron(req, {
corsHeaders: CORS,
secretEnvName: "TEST_CRON_SECRET_6",
headerName: "x-cron-secret",
});
assertEquals(result.ok, true);
Deno.env.delete("TEST_CRON_SECRET_6");
});

// NOTE: testes do authorizeDispatcher dependem de mock de Supabase Auth para
// validar JWT — escopo de integration test, não unit. O fluxo Modo A (secret)
// é simétrico ao authorizeCron e está coberto pelos testes acima.
Loading
Loading