Files
braidsbymira/supabase/migrations/20260812000001_harden_notify_config.sql
belviskhoremk 35e929c5e1 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
2026-08-12 10:15:34 +02:00

113 lines
4.7 KiB
PL/PgSQL

-- 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;
$$;