Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
';
Original file line number Diff line number Diff line change
@@ -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;
Loading