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
113 lines
4.7 KiB
PL/PgSQL
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;
|
|
$$;
|