Items 1,3,4,5 — security migrations (author; apply in Supabase, then re-run advisor)

023_security_hardening.sql:
- Item 1 (CRITICAL, advisor lint 0010): founder_pricing_seats view → recreate
  with security_invoker=on so it respects RLS instead of running as definer.
  (The founder counter no longer depends on it — item 0 uses Stripe directly.)
- Item 3: waitlist write hole — drop the always-true policies, anon may INSERT
  only, update/delete/read via service role.
- Item 5: pin an explicit search_path on the flagged functions (lint 0011).

024_anon_revoke_discoverability.sql:
- Item 4: revoke anon SELECT on the advisor-named tables (accuracy_tracking,
  bets, cascade_alerts, closing_lines, coach_profiles, daily_scan) + a
  commented broad sweep. The frontend reads data via Express (service role),
  never as anon, so this is safe. REVOKE/KEEP rationale documented in the file.

These need Kev to apply (no DB access from here); fingerprint = re-run the
Security Advisor and confirm the lints clear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-18 13:55:39 -04:00
parent 889e8621b4
commit 78c19291c9
2 changed files with 109 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
-- Migration 023 — Security hardening (Chrome ops session follow-up)
-- Author: security follow-up items 1, 3, 5. Apply in the Supabase SQL editor.
-- Idempotent + guarded so a re-run is safe. After applying, re-run the Security
-- Advisor to confirm lint 0010 (security_definer_view) and 0011
-- (function_search_path_mutable) are cleared.
begin;
-- ── Item 1 (CRITICAL, advisor lint 0010) ────────────────────────────────────
-- public.founder_pricing_seats is a SECURITY DEFINER view — it runs with the
-- creator's privileges and ignores RLS, exposed via the public API. Recreate
-- it as security_invoker so it runs with the CALLER's privileges + respects RLS.
-- (The founder counter no longer depends on this view — it now counts real
-- active Stripe subscriptions directly — so this is purely closing the surface.)
do $$
begin
if exists (select 1 from pg_views where schemaname = 'public' and viewname = 'founder_pricing_seats') then
execute 'alter view public.founder_pricing_seats set (security_invoker = on)';
end if;
end $$;
-- ── Item 3 — waitlist write hole ────────────────────────────────────────────
-- public.waitlist had always-true (USING(true)/WITH CHECK(true)) write policies:
-- the anon API could insert/update/delete rows. Drop ALL existing policies and
-- allow anon to INSERT only; updates/deletes/reads go through the service role
-- (which bypasses RLS). Signups are additionally rate-limited at the API layer.
alter table public.waitlist enable row level security;
do $$
declare pol record;
begin
for pol in select policyname from pg_policies where schemaname = 'public' and tablename = 'waitlist' loop
execute format('drop policy %I on public.waitlist', pol.policyname);
end loop;
end $$;
create policy waitlist_anon_insert on public.waitlist for insert to anon with check (true);
-- Remove any lingering table-level write grants from anon; keep INSERT only.
revoke update, delete, truncate on public.waitlist from anon;
revoke select on public.waitlist from anon; -- a signup list is not public
grant insert on public.waitlist to anon;
-- ── Item 5 — function search_path hardening (advisor lint 0011) ──────────────
-- Flagged functions have a mutable search_path (hijackable). Pin an explicit,
-- safe search_path (pg_catalog, public) — resolves the advisor without the
-- breakage risk of '' on functions that reference public objects unqualified.
-- Handles any overload signature.
do $$
declare fn record;
begin
for fn in
select p.oid::regprocedure as sig
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.proname in ('touch_updated_at', 'update_updated_at', 'reset_scan_count')
loop
execute format('alter function %s set search_path = pg_catalog, public', fn.sig);
end loop;
end $$;
commit;
-- NOTE: item 5 lists "and the other flagged functions". Run the Security Advisor
-- (Supabase -> Advisors -> Security, lint 0011) for the full list; add each to
-- the proname IN (...) set above and re-apply. All are the same low-risk change.
@@ -0,0 +1,46 @@
-- Migration 024 — Revoke anon table discoverability (advisor: anon GraphQL/API access)
-- Author: security follow-up item 4. Apply in the Supabase SQL editor.
--
-- ARCHITECTURE FACT this rests on: the VYNDR frontend NEVER reads app-data
-- tables with the Supabase anon key. All data flows browser -> Next proxy ->
-- Express (service role, which bypasses RLS). The anon key is used ONLY for
-- Supabase Auth (login/session). So revoking anon SELECT on app-data tables
-- does not break the app — it just closes the discovery hole the advisor flags.
--
-- REVOKE/KEEP decision (deliberate):
-- REVOKE anon SELECT — every app-data table. The "public record" surfaces
-- (model ledger, accuracy, public profiles) are served by Express under the
-- service role, so even they need no direct anon read.
-- KEEP — nothing needs direct anon table SELECT. (If a future feature reads a
-- genuinely-public view straight from the browser, grant anon SELECT on
-- that SPECIFIC view only, never a base table.)
begin;
-- Explicitly revoke on the advisor-named tables (definitely safe).
do $$
declare t text;
begin
foreach t in array array[
'accuracy_tracking', 'bets', 'cascade_alerts', 'closing_lines',
'coach_profiles', 'daily_scan'
]
loop
if exists (select 1 from information_schema.tables where table_schema = 'public' and table_name = t) then
execute format('revoke select on public.%I from anon', t);
end if;
end loop;
end $$;
-- BROAD SWEEP (recommended — apply after a quick review). Revoke anon SELECT on
-- EVERY existing public table, then leave anon with no base-table discovery.
-- Uncomment to apply; the app does not read tables as anon so this is safe.
--
-- revoke select on all tables in schema public from anon;
-- alter default privileges in schema public revoke select on tables from anon;
--
-- After applying, re-run the Security Advisor to confirm the anon-access lint is
-- cleared. Grant anon SELECT back ONLY on a specific public VIEW if a browser
-- feature ever needs one.
commit;