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
|
||||
|
|
|
|||
26
vercel.json
26
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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue