From 49849c89221bc072b945221c902a5fd97186946b Mon Sep 17 00:00:00 2001 From: atul-upadhyay-7 Date: Wed, 5 Aug 2026 02:17:09 +0530 Subject: [PATCH 1/2] fix: block client INSERT/DELETE on leaderboard to prevent score forgery (#1925) Authenticated users could delete their own leaderboard row and re-insert it with arbitrary xp/streak/badges values, bypassing the UPDATE hardening from previous migrations. Drop the permissive INSERT/DELETE RLS policies and revoke those privileges from anon + authenticated; join_leaderboard() remains the only row-creation path. --- ...block_leaderboard_client_insert_delete.sql | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 supabase/migrations/20260805000002_block_leaderboard_client_insert_delete.sql diff --git a/supabase/migrations/20260805000002_block_leaderboard_client_insert_delete.sql b/supabase/migrations/20260805000002_block_leaderboard_client_insert_delete.sql new file mode 100644 index 00000000..58614882 --- /dev/null +++ b/supabase/migrations/20260805000002_block_leaderboard_client_insert_delete.sql @@ -0,0 +1,61 @@ +-- Close leaderboard score-forgery hole: block client INSERT/DELETE +-- Issue: https://github.com/durdana3105/peer-learning/issues/1925 +-- +-- Problem: The consolidated RLS policies still allow authenticated users to +-- INSERT their own leaderboard row with arbitrary score columns and DELETE it +-- (so they can re-insert with fabricated values). Prior hardening +-- (20260730000001, 20260803000002) only restricted UPDATE, leaving +-- INSERT/DELETE unguarded: +-- +-- delete from public.leaderboard where user_id = auth.uid(); +-- insert into public.leaderboard (user_id, username, xp, streak, ...) +-- values (auth.uid(), 'x', 2147483647, ...); -- instant #1 / top badges +-- +-- Fix: +-- 1. Drop the client INSERT and DELETE policies. +-- 2. REVOKE INSERT and DELETE on leaderboard from anon + authenticated so +-- no client role can forge or clear rows. +-- 3. Keep the SECURITY DEFINER join_leaderboard() RPC as the only legitimate +-- row-creation path (it zero-initializes score fields), and keep the +-- existing hardened UPDATE policy + audit triggers unchanged. + +DROP POLICY IF EXISTS "Users can insert leaderboard entry" ON public.leaderboard; +DROP POLICY IF EXISTS "Users can delete leaderboard entry" ON public.leaderboard; + +REVOKE INSERT, DELETE ON public.leaderboard FROM anon, authenticated; + +-- Document the change in the table comment (appended to the existing one). +COMMENT ON TABLE public.leaderboard IS E' +LEADERBOARD SECURITY MODEL +========================== + +Access Patterns: +1. READ: Any authenticated user can read the full leaderboard (public rankings) +2. CREATE: Only the SECURITY DEFINER join_leaderboard() RPC (zero-initialized) +3. UPDATE: Only specific server-side functions can update scores +4. DELETE: No client role may delete rows (revoked) + +ATTACK PREVENTION: +- INSERT/DELETE revoked from anon and authenticated (#1925) +- RLS Policy: deny_client_score_updates blocks any direct UPDATE +- RLS Policy: users_can_update_profile_only allows profile updates with + strict WITH CHECK conditions that reject score changes +- Audit Trail: All score changes logged in leaderboard_updates table +- Server Functions: All point awards go through secure RPCs with: + - SECURITY DEFINER (execute as role owner) + - Rate limiting to prevent abuse + - Activity validation to ensure legitimate awards + +CLIENT-SIDE BEHAVIOR: +- Frontend code calls award_activity_xp() RPC for XP awards +- RPC validates activity type and rate limits per user +- No direct table writes allowed from client code +- Attempting to bypass RLS will fail at database layer + +This design ensures: +✓ Users cannot forge or delete leaderboard entries +✓ Users cannot modify other users'' scores +✓ All score changes are immutable once written +✓ Complete audit trail of all modifications +✓ No path for score forgery even with session hijacking +'; From 06e72ccfc73b1af72082ff1b6f7db2a043a2ac88 Mon Sep 17 00:00:00 2001 From: atul-upadhyay-7 Date: Wed, 5 Aug 2026 02:18:33 +0530 Subject: [PATCH 2/2] fix: stop world-readable exposure of private session metadata (#1926) The sessions SELECT policy (USING true, no role restriction) exposed every session row - including invite-only is_private=true sessions - to anonymous and authenticated users alike. session_participants had the same open policy. Replace with an anonymous-deny policy plus a scoped authenticated policy that only allows viewing public sessions and sessions the user mentors, is invited to, or participates in. session_participants follows the same access rule. --- ...03_restrict_private_session_visibility.sql | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 supabase/migrations/20260805000003_restrict_private_session_visibility.sql diff --git a/supabase/migrations/20260805000003_restrict_private_session_visibility.sql b/supabase/migrations/20260805000003_restrict_private_session_visibility.sql new file mode 100644 index 00000000..325dab5a --- /dev/null +++ b/supabase/migrations/20260805000003_restrict_private_session_visibility.sql @@ -0,0 +1,80 @@ +-- Restrict session visibility: private session metadata must not be world-readable +-- Issue: https://github.com/durdana3105/peer-learning/issues/1926 +-- +-- Problem: The SELECT policy "Anyone can view sessions" (consolidate migration, +-- no TO clause) lets BOTH anonymous and authenticated users read every row of +-- public.sessions, including invite-only sessions (is_private = true). This +-- leaks private session titles, descriptions, mentor identities, and timing. +-- session_participants has the same open "USING (true)" SELECT, which would +-- leak who attends private sessions. +-- +-- Fix: +-- 1. Drop all broad SELECT policies on sessions. +-- 2. Deny anonymous users outright. +-- 3. Allow authenticated users to see only: +-- - public sessions (is_private = false) +-- - sessions they mentor (mentor_id = auth.uid()) +-- - sessions they are explicitly invited to (session_invites) +-- - sessions they participate in (session_participants) +-- 4. Apply the same access rule to session_participants SELECT. + +-- 1. Drop the broad SELECT policies (any that may exist across migrations). +DROP POLICY IF EXISTS "Anyone can view sessions" ON public.sessions; +DROP POLICY IF EXISTS "Authenticated users can view sessions" ON public.sessions; +DROP POLICY IF EXISTS "sessions_select" ON public.sessions; + +-- 2. Anonymous users cannot view any session rows. +CREATE POLICY "anonymous_cannot_view_sessions" ON public.sessions + FOR SELECT TO anon + USING (false); + +-- 3. Authenticated access is scoped to public / owned / invited / joined. +CREATE POLICY "authenticated_users_can_view_sessions" ON public.sessions + FOR SELECT TO authenticated + USING ( + is_private = false + OR mentor_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.session_invites si + WHERE si.session_id = sessions.id + AND si.user_id = auth.uid() + ) + OR EXISTS ( + SELECT 1 FROM public.session_participants sp + WHERE sp.session_id = sessions.id + AND sp.user_id = auth.uid() + ) + ); + +-- 4. session_participants must follow the same access rule so participant +-- lists of private sessions are not exposed. +DROP POLICY IF EXISTS "Users can view session participants" ON public.session_participants; + +CREATE POLICY "session_participants_scoped_view" ON public.session_participants + FOR SELECT TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.sessions s + WHERE s.id = session_participants.session_id + AND ( + s.is_private = false + OR s.mentor_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.session_invites si + WHERE si.session_id = s.id AND si.user_id = auth.uid() + ) + OR EXISTS ( + SELECT 1 FROM public.session_participants sp + WHERE sp.session_id = s.id AND sp.user_id = auth.uid() + ) + ) + ) + ); + +CREATE POLICY "anonymous_cannot_view_session_participants" ON public.session_participants + FOR SELECT TO anon + USING (false); + +-- Ensure RLS is enabled (defensive). +ALTER TABLE public.sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.session_participants ENABLE ROW LEVEL SECURITY;