fix(launch): close waitlist RLS bypass + open email relay + add security headers

Launch-critical fixes for the public July-14 signup surface (swarm-audited by
MiniMax M3, Claude-verified against the frontend RPC call path):
- migration: DROP the "anon can insert with check(true)" RLS policy. It let
  anon write arbitrary rows straight to the table, bypassing all join_waitlist
  validation. The frontend already calls the join_waitlist RPC (rest/v1/rpc/),
  never a direct insert, so signup is unaffected. RLS stays enabled with no
  permissive INSERT policy = direct anon inserts denied by default.
- join_waitlist: cap email <=254, reject control chars, truncate referrer <=2048.
- waitlist-welcome edge fn: FAIL CLOSED — if WAITLIST_WEBHOOK_SECRET is unset,
  return 401 instead of serving (was an open Resend relay). Guard the referral
  code against ^[a-z2-9]{7}$ and cap email length before sending.
- vercel.json: add baseline security headers (nosniff, Referrer-Policy,
  X-Frame-Options: DENY, HSTS, Permissions-Policy). CSP deferred (would break
  the WebGPU hero's inline bootstrap without nonces) — documented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-27 14:23:58 -05:00
parent b107fd5d7f
commit b8de5c9bf7
3 changed files with 76 additions and 7 deletions

View file

@ -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.

View file

@ -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

View file

@ -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",