Notes: ${input.contact.notes}
` : '' + const serviceName = escapeHtml(input.serviceName) + const name = escapeHtml(input.contact.name) + const phone = escapeHtml(input.contact.phone) + const email = escapeHtml(input.contact.email) + const notesLine = input.contact.notes ? `Notes: ${escapeHtml(input.contact.notes)}
` : '' return { subject: `New booking: ${input.serviceName} on ${input.date}`, - html: `${input.contact.name} booked ${input.serviceName} on ${input.date} at ${input.time}.
` + - `Phone: ${input.contact.phone}
Email: ${input.contact.email}
${name} booked ${serviceName} on ${escapeHtml(input.date)} at ${escapeHtml(input.time)}.
` + + `Phone: ${phone}
Email: ${email}
Notes: ${input.contact.notes}
` : '' + const productName = escapeHtml(input.productName) + const name = escapeHtml(input.contact.name) + const email = escapeHtml(input.contact.email) + const notesLine = input.contact.notes ? `Notes: ${escapeHtml(input.contact.notes)}
` : '' return { subject: `New inquiry: ${input.productName}`, - html: `${input.contact.name} (${input.contact.email}) asked about ${input.productName}.
${notesLine}`, + html: `${name} (${email}) asked about ${productName}.
${notesLine}`, } } diff --git a/supabase/functions/on-booking-created/index.ts b/supabase/functions/on-booking-created/index.ts index 3c65472..4fcca1d 100644 --- a/supabase/functions/on-booking-created/index.ts +++ b/supabase/functions/on-booking-created/index.ts @@ -5,6 +5,14 @@ const FROM_ADDRESS = 'bookings@braidsbymira.com' const MIRA_EMAIL = 'hello@braidsbymira.com' Deno.serve(async (req) => { + const notifySecret = Deno.env.get('NOTIFY_WEBHOOK_SECRET') + if (notifySecret && req.headers.get('X-Notify-Secret') !== notifySecret) { + return new Response(JSON.stringify({ error: 'unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + const payload = await req.json() const booking = payload.record diff --git a/supabase/functions/on-inquiry-created/index.ts b/supabase/functions/on-inquiry-created/index.ts index eb6c67f..f2a556c 100644 --- a/supabase/functions/on-inquiry-created/index.ts +++ b/supabase/functions/on-inquiry-created/index.ts @@ -5,6 +5,14 @@ const FROM_ADDRESS = 'bookings@braidsbymira.com' const MIRA_EMAIL = 'hello@braidsbymira.com' Deno.serve(async (req) => { + const notifySecret = Deno.env.get('NOTIFY_WEBHOOK_SECRET') + if (notifySecret && req.headers.get('X-Notify-Secret') !== notifySecret) { + return new Response(JSON.stringify({ error: 'unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + const payload = await req.json() const inquiry = payload.record diff --git a/supabase/migrations/20260812000001_harden_notify_config.sql b/supabase/migrations/20260812000001_harden_notify_config.sql new file mode 100644 index 0000000..88bc357 --- /dev/null +++ b/supabase/migrations/20260812000001_harden_notify_config.sql @@ -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://