fix: harden Edge Function notification path per final review
Fixes issues raised by the whole-branch review before merge: - Critical: the previous migration hardcoded the live production functions URL and anon key as the committed default, so every local `supabase db reset` silently pointed dev/test triggers at the real project. Adding a new migration instead of editing the already-applied one (checksum/drift safety), with local dev values as the committed default; production values are applied out-of-band, never committed. - Important: verify_jwt=true plus sending the anon key as a bearer token didn't actually stop anyone, since the anon key is public (ships in the frontend bundle) — confirmed empirically, a request with a valid anon key but no shared secret now gets a real 401. Added a random shared secret known only to the database and the Edge Functions' secret store. - Important: notification emails interpolated raw contact fields into HTML with no escaping. Added escapeHtml() and tests proving injected markup renders as literal text, not live markup. - Important: the URL/key/secret functions moved from public to a private schema with EXECUTE revoked from anon/authenticated, closing an unintended PostgREST RPC endpoint (verified 404 after the fix). Also adds a test:functions npm script now that Deno is confirmed available and the shared email builders' tests pass (12 total incl. the new escaping cases). Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
112
supabase/migrations/20260812000001_harden_notify_config.sql
Normal file
112
supabase/migrations/20260812000001_harden_notify_config.sql
Normal file
@@ -0,0 +1,112 @@
|
||||
-- Follow-up to 20260808000003, prompted by final whole-branch review before
|
||||
-- merge. Two problems with the previous migration's approach:
|
||||
--
|
||||
-- 1. It hardcoded the LIVE PRODUCTION base URL and anon key as the committed
|
||||
-- default. Migrations replay on every `supabase db reset`, so any local
|
||||
-- dev/test run was silently wired to the live project — once a Resend key
|
||||
-- is configured, local testing would send real emails to Mira's inbox
|
||||
-- with test data. Fixed by making the committed default the LOCAL dev
|
||||
-- values again; production values are applied out-of-band (see below),
|
||||
-- never committed.
|
||||
--
|
||||
-- 2. `verify_jwt = true` + sending the (public) anon key as a bearer token
|
||||
-- does not actually stop an attacker: the anon key already ships in the
|
||||
-- deployed frontend bundle, so anyone can copy it and POST directly to
|
||||
-- the function URLs to trigger arbitrary outbound emails. Fixed by
|
||||
-- adding a real shared secret (`edge_functions_notify_secret()`), known
|
||||
-- only to this database and the Edge Functions' secret store, checked by
|
||||
-- both functions before doing any work.
|
||||
--
|
||||
-- Both new base-url/anon-key/secret functions move to a `private` schema
|
||||
-- (not exposed via PostgREST — only `public` is listed under `[api]` in
|
||||
-- config.toml) with EXECUTE explicitly revoked from anon/authenticated, so
|
||||
-- they can never become an anon-callable RPC endpoint. The previous
|
||||
-- `public.edge_functions_base_url()` / `public.edge_functions_anon_key()`
|
||||
-- were reachable that way (POST /rest/v1/rpc/edge_functions_anon_key) —
|
||||
-- harmless only because the value itself was already public. Dropped here.
|
||||
--
|
||||
-- PRODUCTION DEPLOY: after this migration is pushed, apply the real values
|
||||
-- out-of-band (never commit these — the anon key is public but the base URL
|
||||
-- is project-specific noise, and the notify secret is a genuine secret):
|
||||
--
|
||||
-- supabase db query --linked "create or replace function private.edge_functions_base_url() returns text language sql immutable as \$\$ select 'https://<project-ref>.supabase.co/functions/v1' \$\$;"
|
||||
-- supabase db query --linked "create or replace function private.edge_functions_anon_key() returns text language sql immutable as \$\$ select '<production anon key>' \$\$;"
|
||||
-- supabase db query --linked "create or replace function private.edge_functions_notify_secret() returns text language sql immutable as \$\$ select '<random secret>' \$\$;"
|
||||
-- supabase secrets set NOTIFY_WEBHOOK_SECRET=<same random secret>
|
||||
-- supabase functions deploy on-booking-created
|
||||
-- supabase functions deploy on-inquiry-created
|
||||
|
||||
create schema if not exists private;
|
||||
|
||||
drop function if exists public.edge_functions_base_url();
|
||||
drop function if exists public.edge_functions_anon_key();
|
||||
|
||||
create or replace function private.edge_functions_base_url()
|
||||
returns text
|
||||
language sql
|
||||
immutable
|
||||
as $$
|
||||
select 'http://host.docker.internal:54321/functions/v1'
|
||||
$$;
|
||||
|
||||
create or replace function private.edge_functions_anon_key()
|
||||
returns text
|
||||
language sql
|
||||
immutable
|
||||
as $$
|
||||
select 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0'
|
||||
$$;
|
||||
|
||||
-- Local dev placeholder only — production sets a real random value via the
|
||||
-- out-of-band command above, never committed to this file.
|
||||
create or replace function private.edge_functions_notify_secret()
|
||||
returns text
|
||||
language sql
|
||||
immutable
|
||||
as $$
|
||||
select 'local-dev-notify-secret'
|
||||
$$;
|
||||
|
||||
revoke execute on function private.edge_functions_base_url() from public, anon, authenticated;
|
||||
revoke execute on function private.edge_functions_anon_key() from public, anon, authenticated;
|
||||
revoke execute on function private.edge_functions_notify_secret() from public, anon, authenticated;
|
||||
|
||||
create or replace function public.notify_booking_created()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
perform net.http_post(
|
||||
url := private.edge_functions_base_url() || '/on-booking-created',
|
||||
headers := jsonb_build_object(
|
||||
'Content-Type', 'application/json',
|
||||
'Authorization', 'Bearer ' || private.edge_functions_anon_key(),
|
||||
'X-Notify-Secret', private.edge_functions_notify_secret()
|
||||
),
|
||||
body := jsonb_build_object('record', to_jsonb(new))
|
||||
);
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.notify_inquiry_created()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
perform net.http_post(
|
||||
url := private.edge_functions_base_url() || '/on-inquiry-created',
|
||||
headers := jsonb_build_object(
|
||||
'Content-Type', 'application/json',
|
||||
'Authorization', 'Bearer ' || private.edge_functions_anon_key(),
|
||||
'X-Notify-Secret', private.edge_functions_notify_secret()
|
||||
),
|
||||
body := jsonb_build_object('record', to_jsonb(new))
|
||||
);
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
Reference in New Issue
Block a user