diff --git a/supabase/functions/waitlist-welcome/index.ts b/supabase/functions/waitlist-welcome/index.ts index 4cdc6a5..50eac69 100644 --- a/supabase/functions/waitlist-welcome/index.ts +++ b/supabase/functions/waitlist-welcome/index.ts @@ -77,9 +77,18 @@ function buildEmail(link: string, xIntent: string) { } Deno.serve(async (req) => { - // optional shared-secret check + // Mandatory shared-secret check (fail closed). If WAITLIST_WEBHOOK_SECRET is + // not configured, refuse to serve — an unauthenticated, internet-reachable + // function that sends Resend email is an open relay / abuse vector. const expected = Deno.env.get("WAITLIST_WEBHOOK_SECRET"); - if (expected && req.headers.get("x-webhook-secret") !== expected) { + if (!expected) { + console.error("WAITLIST_WEBHOOK_SECRET not set; refusing to serve"); + return new Response(JSON.stringify({ ok: false, error: "unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + if (req.headers.get("x-webhook-secret") !== expected) { return new Response(JSON.stringify({ ok: false, error: "unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" }, @@ -106,6 +115,25 @@ Deno.serve(async (req) => { }); } + // Validate inputs before they reach the email HTML / Resend. The referral + // code is interpolated into HTML and a share URL, so constrain it to the + // generator's charset (gen_referral_code: [a-z2-9], 7 chars). Cap the email + // length so an oversized address can't be passed to Resend. Both fields are + // already validated upstream by join_waitlist; this is defense in depth in + // case the webhook payload is spoofed or the schema drifts. + if (!/^[a-z2-9]{7}$/.test(code)) { + return new Response(JSON.stringify({ ok: true, skipped: "bad code" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (email.length > 254) { + return new Response(JSON.stringify({ ok: true, skipped: "bad email" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + const key = Deno.env.get("RESEND_API_KEY"); if (!key) { // Email is optional infra — capture already succeeded. Ack with a note. diff --git a/supabase/migrations/0002_launch_waitlist.sql b/supabase/migrations/0002_launch_waitlist.sql index 6534727..5e27887 100644 --- a/supabase/migrations/0002_launch_waitlist.sql +++ b/supabase/migrations/0002_launch_waitlist.sql @@ -31,12 +31,14 @@ create index if not exists waitlist_referred_by_idx alter table public.waitlist enable row level security; +-- SECURITY: no direct anon INSERT policy. A `with check (true)` policy would +-- let the anon key write ARBITRARY rows straight into the table, bypassing all +-- of join_waitlist's validation (email shape, length caps, control-char +-- rejection, referral-code generation, dedupe). The ONLY anon write path is the +-- `security definer` join_waitlist RPC (granted to anon below), which enforces +-- every invariant. RLS is enabled with no permissive INSERT policy, so direct +-- INSERTs from the anon role are denied by default. drop policy if exists "anon can insert" on public.waitlist; -create policy "anon can insert" - on public.waitlist - for insert - to anon - with check (true); create or replace function public.gen_referral_code() returns text @@ -74,9 +76,22 @@ declare v_ref text := nullif(trim(coalesce(p_referred_by, '')), ''); v_dup boolean := false; begin + -- Abuse / length guards. Per-IP rate limiting needs Supabase edge config + -- (e.g. a gateway rule or pg_net-based throttle) and is tracked separately; + -- at the DB layer we cap field lengths and reject control characters so a + -- single request can't store oversized or malformed payloads. + if length(v_email) > 254 then + raise exception 'invalid email'; + end if; + if v_email ~ '[\x00-\x1f\x7f]' then + raise exception 'invalid email'; + end if; if v_email !~ '^[^@\s]+@[^@\s]+\.[^@\s]+$' then raise exception 'invalid email'; end if; + if p_referrer is not null and length(p_referrer) > 2048 then + p_referrer := left(p_referrer, 2048); + end if; select w.referral_code into v_code from public.waitlist w diff --git a/vercel.json b/vercel.json index d99d935..66f05bd 100644 --- a/vercel.json +++ b/vercel.json @@ -24,8 +24,34 @@ "value": "public, max-age=0, must-revalidate" } ] + }, + { + "source": "/(.*)", + "headers": [ + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin-when-cross-origin" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=63072000; includeSubDomains" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=()" + } + ] } ], + "_csp_note": "CSP TODO: the WebGPU launch page uses SvelteKit inline bootstrap scripts (and likely inline styles); a strict Content-Security-Policy needs nonces/hashes wired through the SvelteKit adapter or it will break the page. Omitting CSP intentionally rather than shipping one that blocks the launch hero. Add later via svelte.config kit.csp once script/style sources are enumerated.", "redirects": [ { "source": "/dashboard/launch",