Add security hardening SQL patch
This commit is contained in:
parent
e29a51e7ea
commit
219670583b
|
|
@ -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());
|
||||||
|
|
||||||
Loading…
Reference in New Issue