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
32 changes: 17 additions & 15 deletions supabase/functions/_shared/dispatcher-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,24 +50,26 @@ 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.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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
50 changes: 35 additions & 15 deletions supabase/functions/_shared/dispatcher-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ const SERVICE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
export type AppRole = "dev" | "supervisor" | "agente";
const ROLE_RANK: Record<AppRole, number> = { 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;
Expand Down Expand Up @@ -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,
),
};
}

Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions supabase/functions/webhook-dispatcher/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <SECRET>` (triggers DB, RPCs, cron)
// - Modo B: `Authorization: Bearer <user JWT>` + 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";
Expand Down
Loading