mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
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:
parent
b107fd5d7f
commit
b8de5c9bf7
3 changed files with 76 additions and 7 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue