feat(security): hardening de auth em webhook-dispatcher e connections-auto-test (Onda 1)#189
Conversation
…-auto-test (Onda 1) Resolve 3 bloqueadores das auditorias 2026-05-10 e 2026-05-13: - N-1 (auditoria 13/05 §3.1): webhook-dispatcher sem auth real - N-2 (auditoria 13/05 §3.2): connections-auto-test sem auth real - C2 (auditoria 13/05): check-no-db-push allowlist atualizada ALTERAÇÕES 1. Novo _shared/dispatcher-auth.ts (294L): - constantTimeEqual() para comparação segura - authorizeDispatcher(): Modo A (x-dispatcher-secret) + Modo B (Bearer JWT user) - authorizeCron(): Modo C (x-cron-secret) - Retrocompat: env nao setado → aceita anonimo com warning - Logs JSON single-line, nunca vaza secret 2. webhook-dispatcher/index.ts: chama authorizeDispatcher() - test_mode e replay_delivery_id exigem Modo B - Mantem HMAC, retries, circuit breaker 3. connections-auto-test/index.ts: chama authorizeCron() - Logica de auto-test inalterada 4. SQL aplicado via MCP apply_migration (NAO db push, conforme ADR 0006): - Vault: 2 secrets de 256 bits (WEBHOOK_DISPATCHER_SECRET, CONNECTIONS_AUTO_TEST_SECRET) - public.get_edge_function_secret() SECURITY DEFINER + whitelist + GRANT só service_role/postgres - dispatch_quote_webhook_event: envia x-dispatcher-secret do vault - retry_failed_webhook_deliveries: envia x-dispatcher-secret do vault - cron connections-auto-test: re-agendado com x-cron-secret - Snapshot em supabase/migrations/20260514112056* e ...57* 5. edge-authz-manifest.ts: rationale atualizado 6. scripts/check-no-db-push.mjs: allowlist expandida (resolve C2) VALIDAÇÕES - deno test _shared/dispatcher-auth.test.ts: 12/12 passed - deno check em 78 edge functions: All clean - Helper get_edge_function_secret validado no DB - Cron + triggers + RPC confirmados no DB com header DEPLOY NOTES - Secrets ja configurados no painel Edge Functions pelo PO - Cron + triggers ja enviam header (aplicado via MCP antes do deploy) - Apos deploy auto da edge: validacao 401/403 ativa - Checklist completo em docs/hardening/ONDA-1-EDGE-AUTH.md ROLLBACK - Re-deploy do commit anterior: triggers/cron continuam enviando header (ignorado pela funcao antiga). Sem perda de funcionalidade.
…aplicado via MCP)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
WalkthroughImplementação de hardening de autenticação em duas edge functions públicas ( ChangesEdge Function Auth Hardening
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🔍 Observações críticas de segurança✅ Pontos fortes:
🐛 Bugs / regressões de risco:
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Pull request overview
Hardens authentication for two Supabase Edge Functions that were previously callable anonymously (webhook-dispatcher and connections-auto-test), adding shared auth helpers and updating SQL callers (trigger/RPC/cron) to send required headers, with documentation for rollout/rollback.
Changes:
- Added
_shared/dispatcher-auth.ts(+ unit tests) to centralize auth for dispatcher/cron callers with retrocompat behavior when secrets aren’t configured. - Updated
webhook-dispatcherandconnections-auto-testedge functions to enforce the new auth modes. - Added snapshot migrations to provision vault secrets + update DB trigger/RPC/cron callers to include auth headers; updated docs and CI allowlist.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| supabase/migrations/20260514112057_edge_function_secrets_callers_hardening.sql | Updates DB trigger/RPC/cron callers to send dispatcher/cron auth headers; includes helper function for vault secret lookup. |
| supabase/migrations/20260514112056_edge_function_secrets_vault_setup.sql | Snapshot for creating the required Vault secrets (placeholders) if absent. |
| supabase/functions/webhook-dispatcher/index.ts | Enforces auth via new shared authorizeDispatcher with user-context requirement for test/replay operations. |
| supabase/functions/connections-auto-test/index.ts | Enforces cron secret auth via new shared authorizeCron. |
| supabase/functions/_shared/edge-authz-manifest.ts | Updates auth rationale entries for the two hardened functions. |
| supabase/functions/_shared/dispatcher-auth.ts | New shared authorization helper (modes A/B/C + retrocompat) and constant-time compare helper. |
| supabase/functions/_shared/dispatcher-auth.test.ts | Unit tests for constantTimeEqual and authorizeCron. |
| scripts/check-no-db-push.mjs | Expands allowlist to permit “supabase db push” mentions in new audit/hardening docs. |
| docs/hardening/ONDA-1-EDGE-AUTH.md | Operational documentation for the auth hardening rollout, rotation, and validation checklist. |
| .tmp-write-probe.md | Temporary diagnostic file added to repo. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // supabase/functions/_shared/dispatcher-auth.ts — sentinel test write | ||
| // (will be overwritten below if push works) |
| * retornar 401. Sempre processa todos os caracteres antes de retornar. | ||
| */ | ||
| export function constantTimeEqual(a: string, b: string): boolean { | ||
| if (typeof a !== "string" || typeof b !== "string") return false; | ||
| if (a.length !== b.length) return false; | ||
| let result = 0; | ||
| for (let i = 0; i < a.length; i++) { | ||
| result |= a.charCodeAt(i) ^ b.charCodeAt(i); | ||
| } |
| _dispatcher_secret := NULL; | ||
| END; | ||
|
|
||
| PERFORM extensions.http_post( | ||
| url := _project_url || '/functions/v1/webhook-dispatcher', | ||
| body := jsonb_build_object('event', _event, 'payload', _payload)::text, | ||
| params := '{}'::jsonb, | ||
| headers := jsonb_build_object( | ||
| 'Content-Type', 'application/json', | ||
| 'x-dispatcher-secret', COALESCE(_dispatcher_secret, '') |
| DECLARE | ||
| _event text; | ||
| _payload jsonb; | ||
| _project_url text := 'https://doufsxqlfjyuvxuezpln.supabase.co'; |
| DECLARE | ||
| v_supabase_url text := 'https://doufsxqlfjyuvxuezpln.supabase.co'; | ||
| v_service_key text; | ||
| v_dispatcher_secret text; |
| headers := jsonb_build_object( | ||
| 'Content-Type', 'application/json', | ||
| 'x-dispatcher-secret', v_dispatcher_secret, | ||
| 'Authorization', COALESCE('Bearer ' || v_service_key, '') |
| SELECT net.http_post( | ||
| url := 'https://doufsxqlfjyuvxuezpln.supabase.co/functions/v1/connections-auto-test', | ||
| headers := jsonb_build_object( | ||
| 'Content-Type', 'application/json', | ||
| 'x-cron-secret', public.get_edge_function_secret('CONNECTIONS_AUTO_TEST_SECRET') | ||
| ), |
| const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; | ||
| const ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? ""; | ||
| const SERVICE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; | ||
|
|
|
|
||
| const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; | ||
| const ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? ""; | ||
| 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 }; | ||
|
|
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with 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.
Inline comments:
In @.tmp-write-probe.md:
- Around line 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.
In `@supabase/functions/_shared/dispatcher-auth.ts`:
- Around line 55-62: A implementação de constantTimeEqual está vazando tempo ao
retornar cedo quando os comprimentos diferem; ajuste a função
(constantTimeEqual) para iterar até Math.max(a.length, b.length) em vez de
retornar quando a.length !== b.length e inclua a diferença de tamanho no
acumulador (por exemplo XOR com 0 para índices fora do range ou incorporar
a.length ^ b.length) para que o result agregue tanto diferenças de bytes quanto
de comprimento, preservando comparação constant-time; mantenha as checagens de
tipo e retorne result === 0 no final.
- Around line 26-27: AppRole and ROLE_RANK are missing the "admin" role which
causes valid admin users to be filtered out by the .filter((r) => r in
ROLE_RANK) check; update the AppRole type to include "admin" and add an "admin"
entry to ROLE_RANK with the appropriate numeric rank (e.g., between agente and
dev or as your access model requires), and ensure any header/module
documentation about supported roles matches these values (also confirm whether
"agente" is intended or should be renamed and adjust both AppRole and ROLE_RANK
accordingly).
In
`@supabase/migrations/20260514112057_edge_function_secrets_callers_hardening.sql`:
- Around line 94-112: The code currently falls back to an empty
x-dispatcher-secret when
public.get_edge_function_secret('WEBHOOK_DISPATCHER_SECRET') fails, causing
silent 401s; change the flow in the block around _dispatcher_secret and the call
to extensions.http_post so that if get_edge_function_secret throws or returns
NULL you do not call extensions.http_post with an empty header: capture the
exception (or check COALESCE result) and instead log the failure and abort early
(e.g., RAISE NOTICE/RAISE EXCEPTION or RETURN with an error) before reaching the
extensions.http_post call; update the block that assigns _dispatcher_secret (and
the subsequent PERFORM extensions.http_post call) to verify _dispatcher_secret
is present and non-empty and fail/log immediately if not.
- Around line 119-195: Add a restrictive ACL for the SECURITY DEFINER function
retry_failed_webhook_deliveries(): after the CREATE OR REPLACE FUNCTION ... END;
block, explicitly revoke all privileges from PUBLIC (REVOKE ALL ON FUNCTION
public.retry_failed_webhook_deliveries() FROM PUBLIC) and then grant execute
only to the privileged roles (GRANT EXECUTE ON FUNCTION
public.retry_failed_webhook_deliveries() TO postgres, service_role) so only
those roles can invoke the RPC that reads the vault secret and uses the service
role key.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 0a655a2f-a7f5-44e4-b1d2-b640461fafe0
📒 Files selected for processing (10)
.tmp-write-probe.mddocs/hardening/ONDA-1-EDGE-AUTH.mdscripts/check-no-db-push.mjssupabase/functions/_shared/dispatcher-auth.test.tssupabase/functions/_shared/dispatcher-auth.tssupabase/functions/_shared/edge-authz-manifest.tssupabase/functions/connections-auto-test/index.tssupabase/functions/webhook-dispatcher/index.tssupabase/migrations/20260514112056_edge_function_secrets_vault_setup.sqlsupabase/migrations/20260514112057_edge_function_secrets_callers_hardening.sql
| // supabase/functions/_shared/dispatcher-auth.ts — sentinel test write | ||
| // (will be overwritten below if push works) |
There was a problem hiding this comment.
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.
| export type AppRole = "dev" | "supervisor" | "agente"; | ||
| const ROLE_RANK: Record<AppRole, number> = { agente: 1, supervisor: 2, dev: 3 }; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== ocorrências de roles e enum/constraint de user_roles =="
rg -n -C2 "\"admin\"|'admin'|\\badmin\\b|\\bsupervisor\\b|\\bagente\\b|\\bdev\\b|user_roles" supabaseRepository: adm01-debug/Promo_Gifts
Length of output: 50380
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== app_role enum definition ==="
rg -n "CREATE TYPE.*app_role|DROP TYPE.*app_role|enum.*app_role" supabase/migrations --max-count=5
echo ""
echo "=== dispatcher-auth.ts header and full context ==="
head -40 supabase/functions/_shared/dispatcher-auth.ts
echo ""
echo "=== Lines 191-193 context in dispatcher-auth.ts ==="
sed -n '185,200p' supabase/functions/_shared/dispatcher-auth.tsRepository: adm01-debug/Promo_Gifts
Length of output: 3143
AppRole type e ROLE_RANK não incluem admin, causando 403 para usuários com esse papel.
O cabeçalho do módulo (linhas 8–11) documenta suporte a admin|supervisor|dev, mas AppRole (linha 26) define apenas "dev" | "supervisor" | "agente" e ROLE_RANK (linha 27) cobre só { agente: 1, supervisor: 2, dev: 3 }.
Na linha 191, o filtro .filter((r) => r in ROLE_RANK) descarta qualquer papel não listado em ROLE_RANK. Usuários com user_roles.role = 'admin' (válido no enum app_role do banco) são silenciosamente filtrados, deixando userRoles vazio e causando falha de autorização (403).
Alinha AppRole com os papéis reais que o sistema deve reconhecer (remova admin do cabeçalho se deletado, ou restaure no código) e confirme se agente é intencional ou deveria ser admin.
🤖 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 `@supabase/functions/_shared/dispatcher-auth.ts` around lines 26 - 27, AppRole
and ROLE_RANK are missing the "admin" role which causes valid admin users to be
filtered out by the .filter((r) => r in ROLE_RANK) check; update the AppRole
type to include "admin" and add an "admin" entry to ROLE_RANK with the
appropriate numeric rank (e.g., between agente and dev or as your access model
requires), and ensure any header/module documentation about supported roles
matches these values (also confirm whether "agente" is intended or should be
renamed and adjust both AppRole and ROLE_RANK accordingly).
| export function constantTimeEqual(a: string, b: string): boolean { | ||
| if (typeof a !== "string" || typeof b !== "string") return false; | ||
| if (a.length !== b.length) return false; | ||
| let result = 0; | ||
| for (let i = 0; i < a.length; i++) { | ||
| result |= a.charCodeAt(i) ^ b.charCodeAt(i); | ||
| } | ||
| return result === 0; |
There was a problem hiding this comment.
A comparação ainda não é constant-time em mismatch de tamanho.
A Line 57 retorna antes do loop quando os comprimentos diferem. Isso vaza timing por length e contradiz o contrato da função justamente no caminho de autenticação por secret. Faça o loop até Math.max(a.length, b.length) e incorpore a diferença de tamanho no acumulador.
💡 Ajuste sugerido
export function constantTimeEqual(a: string, b: string): boolean {
if (typeof a !== "string" || typeof b !== "string") return false;
- if (a.length !== b.length) return false;
- let result = 0;
- for (let i = 0; i < a.length; i++) {
- result |= a.charCodeAt(i) ^ b.charCodeAt(i);
+ const maxLength = Math.max(a.length, b.length);
+ let result = a.length ^ b.length;
+ for (let i = 0; i < maxLength; i++) {
+ result |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
}
return result === 0;
}As per coding guidelines, supabase/functions/**/*.ts: Validação de payload em webhooks (shared secret, assinatura HMAC quando aplicável).
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function constantTimeEqual(a: string, b: string): boolean { | |
| if (typeof a !== "string" || typeof b !== "string") return false; | |
| if (a.length !== b.length) return false; | |
| let result = 0; | |
| for (let i = 0; i < a.length; i++) { | |
| result |= a.charCodeAt(i) ^ b.charCodeAt(i); | |
| } | |
| return result === 0; | |
| export function constantTimeEqual(a: string, b: string): boolean { | |
| if (typeof a !== "string" || typeof b !== "string") return false; | |
| const maxLength = Math.max(a.length, b.length); | |
| let result = a.length ^ b.length; | |
| for (let i = 0; i < maxLength; i++) { | |
| result |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0); | |
| } | |
| return result === 0; | |
| } |
🤖 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 `@supabase/functions/_shared/dispatcher-auth.ts` around lines 55 - 62, A
implementação de constantTimeEqual está vazando tempo ao retornar cedo quando os
comprimentos diferem; ajuste a função (constantTimeEqual) para iterar até
Math.max(a.length, b.length) em vez de retornar quando a.length !== b.length e
inclua a diferença de tamanho no acumulador (por exemplo XOR com 0 para índices
fora do range ou incorporar a.length ^ b.length) para que o result agregue tanto
diferenças de bytes quanto de comprimento, preservando comparação constant-time;
mantenha as checagens de tipo e retorne result === 0 no final.
| BEGIN | ||
| _dispatcher_secret := public.get_edge_function_secret('WEBHOOK_DISPATCHER_SECRET'); | ||
| EXCEPTION WHEN OTHERS THEN | ||
| _dispatcher_secret := NULL; | ||
| END; | ||
|
|
||
| PERFORM extensions.http_post( | ||
| url := _project_url || '/functions/v1/webhook-dispatcher', | ||
| body := jsonb_build_object('event', _event, 'payload', _payload)::text, | ||
| params := '{}'::jsonb, | ||
| headers := jsonb_build_object( | ||
| 'Content-Type', 'application/json', | ||
| 'x-dispatcher-secret', COALESCE(_dispatcher_secret, '') | ||
| ), | ||
| timeout_milliseconds := 5000 | ||
| ); | ||
| RETURN NEW; | ||
| EXCEPTION WHEN OTHERS THEN | ||
| RETURN NEW; |
There was a problem hiding this comment.
Não degrade para x-dispatcher-secret: '' quando o vault falhar.
Se get_edge_function_secret() quebrar, esse bloco ainda faz o http_post com header vazio. Depois que o hardening entrar, isso vira 401 em toda entrega enquanto o EXCEPTION externo continua retornando NEW, então a quebra fica silenciosa. Melhor falhar cedo aqui, com log, em vez de enviar uma chamada garantidamente não autorizada.
💡 Ajuste sugerido
BEGIN
_dispatcher_secret := public.get_edge_function_secret('WEBHOOK_DISPATCHER_SECRET');
EXCEPTION WHEN OTHERS THEN
- _dispatcher_secret := NULL;
+ RAISE LOG 'dispatch_quote_webhook_event: WEBHOOK_DISPATCHER_SECRET unavailable: %', SQLERRM;
+ RETURN NEW;
END;
+
+ IF _dispatcher_secret IS NULL OR _dispatcher_secret = '' THEN
+ RAISE LOG 'dispatch_quote_webhook_event: empty WEBHOOK_DISPATCHER_SECRET; skipping dispatch';
+ RETURN NEW;
+ END IF;
PERFORM extensions.http_post(
url := _project_url || '/functions/v1/webhook-dispatcher',
body := jsonb_build_object('event', _event, 'payload', _payload)::text,
params := '{}'::jsonb,
headers := jsonb_build_object(
'Content-Type', 'application/json',
- 'x-dispatcher-secret', COALESCE(_dispatcher_secret, '')
+ 'x-dispatcher-secret', _dispatcher_secret
),🤖 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
`@supabase/migrations/20260514112057_edge_function_secrets_callers_hardening.sql`
around lines 94 - 112, The code currently falls back to an empty
x-dispatcher-secret when
public.get_edge_function_secret('WEBHOOK_DISPATCHER_SECRET') fails, causing
silent 401s; change the flow in the block around _dispatcher_secret and the call
to extensions.http_post so that if get_edge_function_secret throws or returns
NULL you do not call extensions.http_post with an empty header: capture the
exception (or check COALESCE result) and instead log the failure and abort early
(e.g., RAISE NOTICE/RAISE EXCEPTION or RETURN with an error) before reaching the
extensions.http_post call; update the block that assigns _dispatcher_secret (and
the subsequent PERFORM extensions.http_post call) to verify _dispatcher_secret
is present and non-empty and fail/log immediately if not.
| CREATE OR REPLACE FUNCTION public.retry_failed_webhook_deliveries() | ||
| RETURNS jsonb | ||
| LANGUAGE plpgsql | ||
| SECURITY DEFINER | ||
| SET search_path = public | ||
| AS $function$ | ||
| DECLARE | ||
| v_supabase_url text := 'https://doufsxqlfjyuvxuezpln.supabase.co'; | ||
| v_service_key text; | ||
| v_dispatcher_secret text; | ||
| v_retried int := 0; | ||
| v_skipped int := 0; | ||
| rec record; | ||
| v_max_attempts int; | ||
| BEGIN | ||
| BEGIN | ||
| v_service_key := current_setting('app.supabase_service_role_key', true); | ||
| EXCEPTION WHEN OTHERS THEN | ||
| v_service_key := NULL; | ||
| END; | ||
|
|
||
| BEGIN | ||
| v_dispatcher_secret := public.get_edge_function_secret('WEBHOOK_DISPATCHER_SECRET'); | ||
| EXCEPTION WHEN OTHERS THEN | ||
| v_dispatcher_secret := NULL; | ||
| END; | ||
|
|
||
| IF v_dispatcher_secret IS NULL THEN | ||
| RETURN jsonb_build_object( | ||
| 'ok', false, | ||
| 'error', 'WEBHOOK_DISPATCHER_SECRET not configured in vault' | ||
| ); | ||
| END IF; | ||
|
|
||
| FOR rec IN | ||
| WITH latest AS ( | ||
| SELECT DISTINCT ON (d.webhook_id, d.event, d.payload_hash) | ||
| d.id, d.webhook_id, d.event, d.payload, d.attempt, d.success | ||
| FROM public.webhook_deliveries d | ||
| WHERE d.delivered_at > now() - interval '1 hour' | ||
| ORDER BY d.webhook_id, d.event, d.payload_hash, d.attempt DESC | ||
| ) | ||
| SELECT l.*, w.active, w.retry_policy | ||
| FROM latest l | ||
| JOIN public.outbound_webhooks w ON w.id = l.webhook_id | ||
| WHERE l.success = false AND w.active = true | ||
| LOOP | ||
| v_max_attempts := COALESCE((rec.retry_policy->>'max_attempts')::int, 3); | ||
|
|
||
| IF rec.attempt >= v_max_attempts THEN | ||
| v_skipped := v_skipped + 1; | ||
| CONTINUE; | ||
| END IF; | ||
|
|
||
| PERFORM net.http_post( | ||
| url := v_supabase_url || '/functions/v1/webhook-dispatcher', | ||
| headers := jsonb_build_object( | ||
| 'Content-Type', 'application/json', | ||
| 'x-dispatcher-secret', v_dispatcher_secret, | ||
| 'Authorization', COALESCE('Bearer ' || v_service_key, '') | ||
| ), | ||
| body := jsonb_build_object( | ||
| 'event', rec.event, | ||
| 'payload', rec.payload | ||
| ) | ||
| ); | ||
| v_retried := v_retried + 1; | ||
| END LOOP; | ||
|
|
||
| RETURN jsonb_build_object( | ||
| 'ok', true, | ||
| 'retried', v_retried, | ||
| 'skipped_max_attempts', v_skipped, | ||
| 'ran_at', now() | ||
| ); | ||
| END; | ||
| $function$; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== grants/revokes para retry_failed_webhook_deliveries =="
rg -n -C2 'retry_failed_webhook_deliveries|GRANT EXECUTE ON FUNCTION public\.retry_failed_webhook_deliveries|REVOKE ALL ON FUNCTION public\.retry_failed_webhook_deliveries' supabase/migrationsRepository: adm01-debug/Promo_Gifts
Length of output: 3737
🏁 Script executed:
sed -n '195,220p' supabase/migrations/20260514112057_edge_function_secrets_callers_hardening.sqlRepository: adm01-debug/Promo_Gifts
Length of output: 905
🏁 Script executed:
rg 'REVOKE ALL|GRANT EXECUTE' supabase/migrations/20260514112057_edge_function_secrets_callers_hardening.sqlRepository: adm01-debug/Promo_Gifts
Length of output: 386
Adicione ACL restritiva para retry_failed_webhook_deliveries().
Função criada com SECURITY DEFINER (correto) + SET search_path (correto), mas faltam REVOKE ALL e GRANT EXECUTE. Sem ACL explícito, qualquer role (anon/authenticated/public) consegue executar um RPC privilegiado que:
- Acessa Vault (
WEBHOOK_DISPATCHER_SECRET) - Dispara HTTP POST para edge function com service_role key
- Reprocessa webhooks arbitrários
Use o padrão da migration anterior:
REVOKE ALL ON FUNCTION public.retry_failed_webhook_deliveries() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.retry_failed_webhook_deliveries() TO postgres, service_role;🤖 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
`@supabase/migrations/20260514112057_edge_function_secrets_callers_hardening.sql`
around lines 119 - 195, Add a restrictive ACL for the SECURITY DEFINER function
retry_failed_webhook_deliveries(): after the CREATE OR REPLACE FUNCTION ... END;
block, explicitly revoke all privileges from PUBLIC (REVOKE ALL ON FUNCTION
public.retry_failed_webhook_deliveries() FROM PUBLIC) and then grant execute
only to the privileged roles (GRANT EXECUTE ON FUNCTION
public.retry_failed_webhook_deliveries() TO postgres, service_role) so only
those roles can invoke the RPC that reads the vault secret and uses the service
role key.
Resolve conflitos causados pelo merge de PR #189 em main: - scripts/check-no-db-push.mjs: mantida versão main (allowlist mais completa) - connections-auto-test/index.ts: mantida versão main (authorizeCron() do módulo compartilhado) - webhook-dispatcher/index.ts: auto-merged sem conflito https://claude.ai/code/session_01S3r3Qup4JAPdsvmFsDWNwq
📋 Descrição
Hardening de autenticação em duas edge functions que estavam expostas anonimamente. Resolve N-1, N-2 (auditoria 13/05) e C2 como bônus.
🎯 Tipo de mudança
🔐 Modelo de autorização
webhook-dispatcher— Modo A ou Modo Bx-dispatcher-secret(triggers DB, RPCs, cron)Bearer <user JWT>+ role ≥ supervisor (frontend admin)test_modeereplay_delivery_idexigem Modo B (Modo A → 403)connections-auto-test— Modo Cx-cron-secret(cron a cada 15min)Retrocompat: se env não setada, aceita anônimo com warning log (rollback seguro).
🗄️ Aplicado no banco via MCP apply_migration (NÃO db push, ADR 0006)
vault.secrets: 2 secrets de 256 bitspublic.get_edge_function_secretSECURITY DEFINER + whitelist + GRANT sóservice_role/postgresdispatch_quote_webhook_eventlê vault e enviax-dispatcher-secretretry_failed_webhook_deliverieslê vault e enviax-dispatcher-secretconnections-auto-testre-agendado comx-cron-secretSnapshots SQL em
supabase/migrations/20260514112056*e...57*.✅ Validações
deno test _shared/dispatcher-auth.test.ts: 12/12 passed (28ms)deno checkem 78 edge functions: all cleannode scripts/check-no-db-push.mjs: passa🚀 Deploy
deploy-edge-functions.ymldeploya automaticamente → validação 401/403 fica ativa📋 Checklist pós-deploy
Detalhes em
docs/hardening/ONDA-1-EDGE-AUTH.md:webhook_deliveriesganha linha success=truecurlanônimo → 401curlcom secret errado → 401curl test_modecomx-dispatcher-secret→ 403🔙 Rollback
Re-deploy do commit anterior. Triggers/cron continuam enviando o header (ignorado pela função antiga). Sem perda de funcionalidade.
📌 Notas
.tmp-write-probe.mdcriado durante diagnóstico de permissões — deletar manualmente após mergeSummary by CodeRabbit
Notas da Versão
Melhorias de Segurança
Documentação