From 036baf9f6dcb8fc8b0326321231bc560ca167f17 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 12:05:08 +0000 Subject: [PATCH] Fix profile data exposure --- ...0_d39a795e-c84d-4311-a192-14d074645e7c.sql | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 supabase/migrations/20251009120430_d39a795e-c84d-4311-a192-14d074645e7c.sql diff --git a/supabase/migrations/20251009120430_d39a795e-c84d-4311-a192-14d074645e7c.sql b/supabase/migrations/20251009120430_d39a795e-c84d-4311-a192-14d074645e7c.sql new file mode 100644 index 00000000..c673df80 --- /dev/null +++ b/supabase/migrations/20251009120430_d39a795e-c84d-4311-a192-14d074645e7c.sql @@ -0,0 +1,126 @@ +-- Revert the previous policy and implement proper security model +-- The filtered_profiles view should be the ONLY way to view other users' profiles + +-- Remove the policy that allows direct profile viewing +DROP POLICY IF EXISTS "Authenticated users can view public profiles" ON public.profiles; + +-- Change filtered_profiles view to use SECURITY DEFINER +-- This allows it to bypass RLS and implement its own access control +DROP VIEW IF EXISTS public.filtered_profiles; + +CREATE VIEW public.filtered_profiles +WITH (security_invoker = off, security_barrier = true) +AS +SELECT + p.id, + p.user_id, + p.username, + p.display_name, + p.privacy_level, + p.created_at, + p.updated_at, + -- Conditionally show bio based on privacy + CASE + WHEN can_view_profile_field(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid), p.user_id, 'bio') + THEN p.bio + ELSE NULL + END AS bio, + -- Conditionally show avatar based on privacy + CASE + WHEN can_view_profile_field(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid), p.user_id, 'avatar_url') + THEN p.avatar_url + ELSE NULL + END AS avatar_url, + CASE + WHEN can_view_profile_field(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid), p.user_id, 'avatar_image_id') + THEN p.avatar_image_id + ELSE NULL + END AS avatar_image_id, + -- Conditionally show pronouns based on privacy + CASE + WHEN can_view_profile_field(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid), p.user_id, 'preferred_pronouns') + THEN p.preferred_pronouns + ELSE NULL + END AS preferred_pronouns, + CASE + WHEN can_view_profile_field(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid), p.user_id, 'preferred_pronouns') + THEN p.show_pronouns + ELSE false + END AS show_pronouns, + -- Conditionally show location based on privacy + CASE + WHEN can_view_profile_field(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid), p.user_id, 'personal_location') + THEN p.personal_location + ELSE NULL + END AS personal_location, + CASE + WHEN can_view_profile_field(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid), p.user_id, 'location_id') + THEN p.location_id + ELSE NULL + END AS location_id, + -- Conditionally show home park based on privacy + CASE + WHEN can_view_profile_field(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid), p.user_id, 'home_park_id') + THEN p.home_park_id + ELSE NULL + END AS home_park_id, + -- Conditionally show date of birth based on privacy + CASE + WHEN can_view_profile_field(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid), p.user_id, 'date_of_birth') + THEN p.date_of_birth + ELSE NULL + END AS date_of_birth, + -- Always show safe fields + p.timezone, + p.preferred_language, + p.theme_preference, + -- Conditionally show activity stats based on privacy + CASE + WHEN can_view_profile_field(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid), p.user_id, 'ride_count') + THEN p.ride_count + ELSE 0 + END AS ride_count, + CASE + WHEN can_view_profile_field(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid), p.user_id, 'ride_count') + THEN p.coaster_count + ELSE 0 + END AS coaster_count, + CASE + WHEN can_view_profile_field(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid), p.user_id, 'ride_count') + THEN p.park_count + ELSE 0 + END AS park_count, + CASE + WHEN can_view_profile_field(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid), p.user_id, 'ride_count') + THEN p.review_count + ELSE 0 + END AS review_count, + CASE + WHEN can_view_profile_field(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid), p.user_id, 'ride_count') + THEN p.reputation_score + ELSE 0 + END AS reputation_score, + -- Only show banned status to owner and moderators + CASE + WHEN (auth.uid() = p.user_id) OR is_moderator(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid)) + THEN p.banned + ELSE false + END AS banned +FROM public.profiles p +WHERE + -- Hide banned profiles unless viewer is owner or moderator + ((NOT p.banned) OR (auth.uid() = p.user_id) OR is_moderator(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid))) + AND + -- Only show public profiles OR owner's own profile OR moderator viewing + ((p.privacy_level = 'public') OR (auth.uid() = p.user_id) OR is_moderator(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid))); + +-- Grant access to the filtered_profiles view +GRANT SELECT ON public.filtered_profiles TO authenticated, anon; + +-- Add explanatory comment +COMMENT ON VIEW public.filtered_profiles IS +'Privacy-safe profile view that enforces field-level access control. +Uses security_invoker=off (SECURITY DEFINER) to bypass RLS and implement custom filtering. +Sensitive fields are conditionally shown based on user privacy settings. +Users can only see public non-banned profiles, their own profile, or all profiles if they are moderators. +All client code should query this view instead of the profiles table directly.'; \ No newline at end of file