diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..7f10836 --- /dev/null +++ b/deno.lock @@ -0,0 +1,50 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@*": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.14" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + } + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:@eslint/js@^10.0.1", + "npm:@react-three/fiber@^9.7.0", + "npm:@supabase/supabase-js@^2.112.2", + "npm:@testing-library/jest-dom@7", + "npm:@testing-library/react@^16.3.2", + "npm:@testing-library/user-event@^14.6.1", + "npm:@types/node@^24.13.3", + "npm:@types/react-dom@^19.2.3", + "npm:@types/react@^19.2.17", + "npm:@types/three@~0.185.3", + "npm:@vitejs/plugin-react@^6.0.4", + "npm:eslint-plugin-react-hooks@^7.1.1", + "npm:eslint-plugin-react-refresh@~0.5.3", + "npm:eslint@^10.8.0", + "npm:framer-motion@^12.43.0", + "npm:globals@^17.7.0", + "npm:jsdom@^30.0.1", + "npm:react-dom@^19.2.8", + "npm:react-router-dom@^7.18.2", + "npm:react@^19.2.8", + "npm:three@~0.185.1", + "npm:tsx@^4.23.11", + "npm:typescript-eslint@^8.65.0", + "npm:typescript@~6.0.2", + "npm:vite@^8.2.0", + "npm:vitest@^4.1.10" + ] + } + } +} diff --git a/package.json b/package.json index d52d1cf..1ea5856 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint .", "preview": "vite preview", "test": "vitest run", + "test:functions": "deno test supabase/functions/", "seed": "tsx scripts/seed.ts" }, "dependencies": { diff --git a/supabase/functions/_shared/email.test.ts b/supabase/functions/_shared/email.test.ts index 9961f06..6df560c 100644 --- a/supabase/functions/_shared/email.test.ts +++ b/supabase/functions/_shared/email.test.ts @@ -47,3 +47,34 @@ Deno.test('buildInquiryNotificationEmail omits the notes paragraph when notes ar }) if (html.includes('Notes:')) throw new Error('expected no Notes: paragraph') }) + +Deno.test('buildBookingNotificationEmail escapes HTML in contact fields so they cannot inject markup', () => { + const { html } = buildBookingNotificationEmail({ + serviceName: 'Knotless Braids', + date: '2026-09-01', + time: '10:00 AM', + contact: { + name: 'Click here', + phone: '555-0100', + email: 'jane@example.com', + notes: '', + }, + }) + if (html.includes('')) throw new Error('expected contact name to be escaped') + if (html.includes('')) throw new Error('expected notes to be escaped') + assertStringIncludes(html, '<a href="https://evil.example">Click here</a>') + assertStringIncludes(html, '<img src=x onerror=alert(1)>') +}) + +Deno.test('buildInquiryNotificationEmail escapes HTML in contact fields so they cannot inject markup', () => { + const { html } = buildInquiryNotificationEmail({ + productName: 'Silk Edge Control', + contact: { + name: '', + email: 'nadia@example.com', + notes: '', + }, + }) + if (html.includes('')) throw new Error('expected contact name to be escaped') + assertStringIncludes(html, '<script>alert(1)</script>') +}) diff --git a/supabase/functions/_shared/email.ts b/supabase/functions/_shared/email.ts index cf8eb89..8e6b73a 100644 --- a/supabase/functions/_shared/email.ts +++ b/supabase/functions/_shared/email.ts @@ -1,3 +1,12 @@ +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + export interface BookingEmailInput { serviceName: string date: string @@ -6,11 +15,15 @@ export interface BookingEmailInput { } export function buildBookingNotificationEmail(input: BookingEmailInput): { subject: string; html: string } { - const notesLine = input.contact.notes ? `

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}

${notesLine}`, + html: `

${name} booked ${serviceName} on ${escapeHtml(input.date)} at ${escapeHtml(input.time)}.

` + + `

Phone: ${phone}
Email: ${email}

${notesLine}`, } } @@ -20,9 +33,12 @@ export interface InquiryEmailInput { } export function buildInquiryNotificationEmail(input: InquiryEmailInput): { subject: string; html: string } { - const notesLine = input.contact.notes ? `

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://.supabase.co/functions/v1' \$\$;" +-- supabase db query --linked "create or replace function private.edge_functions_anon_key() returns text language sql immutable as \$\$ select '' \$\$;" +-- supabase db query --linked "create or replace function private.edge_functions_notify_secret() returns text language sql immutable as \$\$ select '' \$\$;" +-- supabase secrets set NOTIFY_WEBHOOK_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; +$$;