diff --git a/supabase/functions/_shared/dispatcher-auth.test.ts b/supabase/functions/_shared/dispatcher-auth.test.ts index a45e7a940..1fd06a316 100644 --- a/supabase/functions/_shared/dispatcher-auth.test.ts +++ b/supabase/functions/_shared/dispatcher-auth.test.ts @@ -50,24 +50,26 @@ function makeRequest(headers: Record = {}): Request { return new Request("https://example.com/", { method: "POST", headers }); } -Deno.test("authorizeCron: env nao setada => legacy_no_auth (retrocompat)", () => { +Deno.test("authorizeCron: env nao setada => fail-closed 503 (SEC-003)", async () => { Deno.env.delete("TEST_CRON_SECRET_1"); const req = makeRequest({}); - const result = authorizeCron(req, { + const result = await 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"); + assertEquals(result.ok, false); + if (!result.ok) { + assertEquals(result.response.status, 503); + const body = await result.response.json(); + assertEquals(body.error, "service_misconfigured"); } }); -Deno.test("authorizeCron: env setada + sem header => 401", () => { +Deno.test("authorizeCron: env setada + sem header => 401", async () => { Deno.env.set("TEST_CRON_SECRET_2", "supersecret123"); const req = makeRequest({}); - const result = authorizeCron(req, { + const result = await authorizeCron(req, { corsHeaders: CORS, secretEnvName: "TEST_CRON_SECRET_2", headerName: "x-cron-secret", @@ -79,10 +81,10 @@ Deno.test("authorizeCron: env setada + sem header => 401", () => { Deno.env.delete("TEST_CRON_SECRET_2"); }); -Deno.test("authorizeCron: env setada + header correto => ok (modo secret)", () => { +Deno.test("authorizeCron: env setada + header correto => ok (modo secret)", async () => { Deno.env.set("TEST_CRON_SECRET_3", "supersecret123"); const req = makeRequest({ "x-cron-secret": "supersecret123" }); - const result = authorizeCron(req, { + const result = await authorizeCron(req, { corsHeaders: CORS, secretEnvName: "TEST_CRON_SECRET_3", headerName: "x-cron-secret", @@ -94,10 +96,10 @@ Deno.test("authorizeCron: env setada + header correto => ok (modo secret)", () = Deno.env.delete("TEST_CRON_SECRET_3"); }); -Deno.test("authorizeCron: env setada + header errado => 401", () => { +Deno.test("authorizeCron: env setada + header errado => 401", async () => { Deno.env.set("TEST_CRON_SECRET_4", "supersecret123"); const req = makeRequest({ "x-cron-secret": "wrong_secret" }); - const result = authorizeCron(req, { + const result = await authorizeCron(req, { corsHeaders: CORS, secretEnvName: "TEST_CRON_SECRET_4", headerName: "x-cron-secret", @@ -109,12 +111,12 @@ Deno.test("authorizeCron: env setada + header errado => 401", () => { Deno.env.delete("TEST_CRON_SECRET_4"); }); -Deno.test("authorizeCron: header de tamanho diferente nao causa timing leak", () => { +Deno.test("authorizeCron: header de tamanho diferente nao causa timing leak", async () => { // 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, { + const result = await authorizeCron(req, { corsHeaders: CORS, secretEnvName: "TEST_CRON_SECRET_5", headerName: "x-cron-secret", @@ -123,11 +125,11 @@ Deno.test("authorizeCron: header de tamanho diferente nao causa timing leak", () Deno.env.delete("TEST_CRON_SECRET_5"); }); -Deno.test("authorizeCron: secret com chars especiais (base64)", () => { +Deno.test("authorizeCron: secret com chars especiais (base64)", async () => { const realSecret = "j/nKCXCqyvYgucMAX1wuHJO6QhEDPVaWLWoIsqlfp+o="; Deno.env.set("TEST_CRON_SECRET_6", realSecret); const req = makeRequest({ "x-cron-secret": realSecret }); - const result = authorizeCron(req, { + const result = await authorizeCron(req, { corsHeaders: CORS, secretEnvName: "TEST_CRON_SECRET_6", headerName: "x-cron-secret", diff --git a/supabase/functions/_shared/dispatcher-auth.ts b/supabase/functions/_shared/dispatcher-auth.ts index d36bf23e5..5e29bc048 100644 --- a/supabase/functions/_shared/dispatcher-auth.ts +++ b/supabase/functions/_shared/dispatcher-auth.ts @@ -24,8 +24,8 @@ const SERVICE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; export type AppRole = "dev" | "supervisor" | "agente"; const ROLE_RANK: Record = { agente: 1, supervisor: 2, dev: 3 }; -export type DispatcherAuthMode = "secret" | "user_jwt" | "legacy_no_auth"; -export type CronAuthMode = "secret" | "secret_vault" | "legacy_no_auth"; +export type DispatcherAuthMode = "secret" | "user_jwt"; +export type CronAuthMode = "secret" | "secret_vault"; export interface DispatcherAuthOk { ok: true; @@ -236,19 +236,27 @@ export async function authorizeDispatcher( }; } - // Retrocompat: nenhum env setado → aceita anônimo com warning + // Fail-closed: secret obrigatório em produção (auditoria SEC-003). + // Antes aceitávamos chamada anônima como retrocompat ("legacy_no_auth"), + // o que abria webhook-dispatcher para qualquer caller quando o secret + // não estava configurado (clones de staging/dev, vault revogado, etc.). + // Agora devolvemos 503 explícito — exige configuração para operar. if (!expectedSecret) { logAuthEvent({ - outcome: "allowed", - mode: "legacy_no_auth", - warning: "WEBHOOK_DISPATCHER_SECRET nao configurado — aceitando chamada anonima. Configure secret para hardening.", + outcome: "denied", + reason: "secret_not_configured", + env: "WEBHOOK_DISPATCHER_SECRET", }); return { - ok: true, - mode: "legacy_no_auth", - supabaseAdmin: createClient(SUPABASE_URL, SERVICE_KEY, { - auth: { persistSession: false, autoRefreshToken: false }, - }), + ok: false, + response: jsonResponse( + { + error: "service_misconfigured", + message: "WEBHOOK_DISPATCHER_SECRET não configurado. Configure no vault (integration_credentials) ou env antes de invocar.", + }, + 503, + corsHeaders, + ), }; } @@ -283,14 +291,26 @@ export async function authorizeCron( const providedSecret = req.headers.get(headerName) ?? ""; + // Fail-closed: secret obrigatório (auditoria SEC-003). Antes aceitávamos + // crons anônimos como retrocompat — risco de qualquer caller acionar jobs + // quando o secret não estivesse setado no vault E no env. Agora 503. if (!expectedSecret) { logAuthEvent({ - outcome: "allowed", - mode: "legacy_no_auth", + outcome: "denied", + reason: "secret_not_configured", env: secretEnvName, - warning: `${secretEnvName} nao configurado em vault nem env — aceitando chamada anonima. Configure para hardening.`, }); - return { ok: true, mode: "legacy_no_auth" }; + return { + ok: false, + response: jsonResponse( + { + error: "service_misconfigured", + message: `${secretEnvName} não configurado em vault nem env. Configure antes de invocar este cron.`, + }, + 503, + corsHeaders, + ), + }; } if (!providedSecret) { diff --git a/supabase/functions/webhook-dispatcher/index.ts b/supabase/functions/webhook-dispatcher/index.ts index da60088cc..cd667ca6a 100644 --- a/supabase/functions/webhook-dispatcher/index.ts +++ b/supabase/functions/webhook-dispatcher/index.ts @@ -2,11 +2,12 @@ // subscribed to that event. HMAC signs payload with webhook secret. Retries // with backoff and logs each attempt to webhook_deliveries. // -// AUTORIZAÇÃO (Onda 1 hardening, 2026-05-14): +// AUTORIZAÇÃO (SEC-003 fail-closed, 2026-05-22): // - Modo A: header `x-dispatcher-secret: ` (triggers DB, RPCs, cron) // - Modo B: `Authorization: Bearer ` + role >= supervisor (frontend) // - test_mode e replay_delivery_id exigem Modo B (operação sensível) -// - Retrocompat: se WEBHOOK_DISPATCHER_SECRET não estiver setado, aceita anônimo com warning +// - Se WEBHOOK_DISPATCHER_SECRET não estiver configurado em vault/env, +// authorizeDispatcher devolve 503 service_misconfigured (fail-closed). // // Ver: supabase/functions/_shared/dispatcher-auth.ts import { crypto } from "https://deno.land/std@0.224.0/crypto/mod.ts";