From 78c19291c936472ac42ce9be699f6573fafcebf3 Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 18 Jul 2026 13:55:39 -0400 Subject: [PATCH] =?UTF-8?q?Items=201,3,4,5=20=E2=80=94=20security=20migrat?= =?UTF-8?q?ions=20(author;=20apply=20in=20Supabase,=20then=20re-run=20advi?= =?UTF-8?q?sor)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- migrations/023_security_hardening.sql | 63 +++++++++++++++++++ .../024_anon_revoke_discoverability.sql | 46 ++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 migrations/023_security_hardening.sql create mode 100644 migrations/024_anon_revoke_discoverability.sql diff --git a/migrations/023_security_hardening.sql b/migrations/023_security_hardening.sql new file mode 100644 index 0000000..2f12363 --- /dev/null +++ b/migrations/023_security_hardening.sql @@ -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. diff --git a/migrations/024_anon_revoke_discoverability.sql b/migrations/024_anon_revoke_discoverability.sql new file mode 100644 index 0000000..715ceac --- /dev/null +++ b/migrations/024_anon_revoke_discoverability.sql @@ -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;