From 219670583b6ec6463f77253b9b129aaefddf4aad Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Apr 2026 15:21:31 +0300 Subject: [PATCH] Add security hardening SQL patch --- supabase/security-hardening.sql | 122 ++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 supabase/security-hardening.sql diff --git a/supabase/security-hardening.sql b/supabase/security-hardening.sql new file mode 100644 index 0000000..cd3497c --- /dev/null +++ b/supabase/security-hardening.sql @@ -0,0 +1,122 @@ +-- Security hardening migration for OTP, public delivery invitations, and Edge Function rate limits. +-- Run this file once in Supabase SQL Editor before deploying the updated Edge Functions. + +create extension if not exists pgcrypto; + +create table if not exists public.rate_limits ( + id uuid primary key default gen_random_uuid(), + scope text not null, + rate_key text not null, + window_start timestamptz not null, + count integer not null default 1, + blocked_until timestamptz, + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()), + unique (scope, rate_key, window_start) +); + +alter table public.delivery_invitations add column if not exists expires_at timestamptz; +alter table public.delivery_invitations add column if not exists revoked_at timestamptz; +alter table public.delivery_invitations add column if not exists access_count integer not null default 0; +alter table public.delivery_invitations add column if not exists last_accessed_at timestamptz; + +create index if not exists idx_delivery_invitations_expires_at +on public.delivery_invitations (expires_at); + +create index if not exists idx_rate_limits_scope_key_window +on public.rate_limits (scope, rate_key, window_start desc); + +create index if not exists idx_rate_limits_blocked_until +on public.rate_limits (blocked_until); + +create or replace function public.check_rate_limit( + p_scope text, + p_key text, + p_max_count integer, + p_window_seconds integer, + p_block_seconds integer default 0 +) +returns table ( + allowed boolean, + current_count integer, + limit_count integer, + blocked_until timestamptz, + window_start timestamptz +) +language plpgsql +security definer +set search_path = public +as $function$ +declare + v_now timestamptz := timezone('utc', now()); + v_window_start timestamptz; + v_count integer; + v_blocked_until timestamptz; +begin + if p_max_count <= 0 then + raise exception 'max_count must be positive'; + end if; + + if p_window_seconds <= 0 then + raise exception 'window_seconds must be positive'; + end if; + + v_window_start := to_timestamp( + floor(extract(epoch from v_now) / p_window_seconds) * p_window_seconds + ); + + select rl.blocked_until + into v_blocked_until + from public.rate_limits rl + where rl.scope = p_scope + and rl.rate_key = p_key + and rl.blocked_until is not null + and rl.blocked_until > v_now + order by rl.blocked_until desc + limit 1; + + if v_blocked_until is not null then + return query + select false, 0, p_max_count, v_blocked_until, v_window_start; + return; + end if; + + insert into public.rate_limits (scope, rate_key, window_start, count, blocked_until) + values (p_scope, p_key, v_window_start, 1, null) + on conflict (scope, rate_key, window_start) + do update set + count = public.rate_limits.count + 1, + blocked_until = case + when public.rate_limits.count + 1 > p_max_count and p_block_seconds > 0 then greatest( + coalesce(public.rate_limits.blocked_until, v_now), + v_now + make_interval(secs => p_block_seconds) + ) + else public.rate_limits.blocked_until + end, + updated_at = v_now + returning count, blocked_until + into v_count, v_blocked_until; + + return query + select + v_count <= p_max_count and (v_blocked_until is null or v_blocked_until <= v_now), + v_count, + p_max_count, + v_blocked_until, + v_window_start; +end; +$function$; + +alter table public.rate_limits enable row level security; + +drop policy if exists "rate limits admin only" on public.rate_limits; +create policy "rate limits admin only" on public.rate_limits +for all +using (public.current_role_name() = 'admin') +with check (public.current_role_name() = 'admin'); + +drop policy if exists "users self or admin" on public.users; +create policy "users self or admin" on public.users +for select +using (public.current_role_name() = 'admin' or id = auth.uid()); +