From e873e248f1ac45d17348513919a682f0172cf730 Mon Sep 17 00:00:00 2001 From: atul-upadhyay-7 Date: Wed, 5 Aug 2026 02:15:09 +0530 Subject: [PATCH] fix: stop exposing every user's email address through profiles RLS (#1924) Revoke column-level SELECT on profiles.email from anon and authenticated roles so only the row owner and service role can read it, and drop the permissive SELECT policies on the legacy public.users compat table. Update the client to select only non-sensitive profile columns instead of '*' (useMessages, Chat, Profile, EditProfile) and remove email-based fallbacks in the chat UI. --- src/components/messages/Sidebar.tsx | 3 +- src/components/messages/utils.ts | 6 +-- src/hooks/useMessages.ts | 6 +-- src/pages/Chat.tsx | 9 +++-- src/pages/EditProfile.tsx | 4 +- src/pages/Profile.tsx | 4 +- ...60805000001_protect_profiles_email_rls.sql | 40 +++++++++++++++++++ 7 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 supabase/migrations/20260805000001_protect_profiles_email_rls.sql diff --git a/src/components/messages/Sidebar.tsx b/src/components/messages/Sidebar.tsx index aad1aa86..4bd82ed9 100644 --- a/src/components/messages/Sidebar.tsx +++ b/src/components/messages/Sidebar.tsx @@ -97,7 +97,6 @@ export function Sidebar({ return conversationSummaries.filter(({ profile, lastMessage }) => { const haystack = [ getDisplayName(profile), - profile.email ?? "", getRoleLabel(profile), lastMessage ? getMessageBody(lastMessage) : "", ] @@ -114,7 +113,7 @@ export function Sidebar({ if (profile.id === currentUserId) return false; if (!query) return true; - const haystack = [getDisplayName(profile), profile.email ?? "", getRoleLabel(profile)] + const haystack = [getDisplayName(profile), getRoleLabel(profile)] .join(" ") .toLowerCase(); return haystack.includes(query); diff --git a/src/components/messages/utils.ts b/src/components/messages/utils.ts index b1dbc503..820c7053 100644 --- a/src/components/messages/utils.ts +++ b/src/components/messages/utils.ts @@ -1,9 +1,9 @@ import { ProfileSummary, MessageRow } from "@/hooks/useMessages"; -export const getDisplayName = (profile?: Pick | null) => - profile?.name?.trim() || profile?.email?.split("@")[0] || "Learner"; +export const getDisplayName = (profile?: Pick | null) => + profile?.name?.trim() || "Learner"; -export const getInitial = (profile?: Pick | null) => +export const getInitial = (profile?: Pick | null) => getDisplayName(profile).charAt(0).toUpperCase(); export const getMessageBody = (message: MessageRow) => diff --git a/src/hooks/useMessages.ts b/src/hooks/useMessages.ts index dd95c378..b799febd 100644 --- a/src/hooks/useMessages.ts +++ b/src/hooks/useMessages.ts @@ -8,7 +8,6 @@ import { sanitizeMessageContent } from "@/utils/sanitize"; export type ProfileSummary = { id: string; name: string | null; - email: string | null; avatar_url: string | null; is_mentor: boolean; is_learner: boolean; @@ -82,7 +81,6 @@ const THREAD_PAGE_SIZE = 50; const normalizeProfile = (row: ProfileRow | UserRow): ProfileSummary => ({ id: row.id, name: row.name, - email: row.email, avatar_url: row.avatar_url ?? null, is_mentor: "is_mentor" in row ? row.is_mentor : false, is_learner: "is_learner" in row ? row.is_learner : false, @@ -232,7 +230,9 @@ export function useMessages( try { const { data: profileData, error: profileError } = await supabase .from("profiles") - .select("*") + // SECURITY (#1924): never select email — only the non-sensitive + // columns needed for the peer directory are fetched. + .select("id, name, avatar_url, is_mentor, is_learner, last_active, last_seen") .neq("id", currentUserId) .order("name", { ascending: true }) .limit(100); diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index 7a061d44..2e63a6e1 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -15,7 +15,6 @@ const MarkdownRenderer = React.lazy(() => type Profile = { id: string; name: string | null; - email: string | null; avatar_url?: string | null; }; @@ -100,7 +99,7 @@ const ChatBubble = memo( ); const getDisplayName = (profile?: Profile | null) => - profile?.name || profile?.email?.split("@")[0] || "Learner"; + profile?.name?.trim() || "Learner"; const getInitial = (profile?: Profile | null) => getDisplayName(profile).charAt(0).toUpperCase(); @@ -137,7 +136,7 @@ const Chat = () => { if (!query) return users; return users.filter((user) => - `${user.name ?? ""} ${user.email ?? ""}`.toLowerCase().includes(query) + `${user.name ?? ""}`.toLowerCase().includes(query) ); }, [search, users]); @@ -169,7 +168,9 @@ const Chat = () => { const { data: profileData } = await supabase .from("profiles") - .select("*") + // SECURITY (#1924): select only the non-sensitive columns rendered in + // the chat sidebar — never email. + .select("id, name, avatar_url") .neq("id", currentUser.id) .order("name", { ascending: true }) .limit(100); diff --git a/src/pages/EditProfile.tsx b/src/pages/EditProfile.tsx index 51cccc5a..5634501f 100644 --- a/src/pages/EditProfile.tsx +++ b/src/pages/EditProfile.tsx @@ -31,7 +31,9 @@ const EditProfile = () => { const { data } = await supabase .from("profiles") - .select("*") + // SECURITY (#1924): select only the editable columns rendered here — + // never email. + .select("id, name, bio, skills") .eq("id", user.id) .maybeSingle(); diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 02ec1399..8020f3f8 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -52,7 +52,9 @@ const Profile = () => { const { data: rawProfileData, error: profileError } = await supabase .from("profiles") - .select("*") + // SECURITY (#1924): select only the columns rendered on this page — + // never email. + .select("id, name, bio, skills, avatar_url, streak, points") .eq("id", user.id) .single(); diff --git a/supabase/migrations/20260805000001_protect_profiles_email_rls.sql b/supabase/migrations/20260805000001_protect_profiles_email_rls.sql new file mode 100644 index 00000000..ad97daef --- /dev/null +++ b/supabase/migrations/20260805000001_protect_profiles_email_rls.sql @@ -0,0 +1,40 @@ +-- Protect user email addresses from bulk disclosure +-- Issue: https://github.com/durdana3105/peer-learning/issues/1924 +-- +-- Problem: The profiles table SELECT policy ("authenticated_users_can_view_profiles" +-- USING (true)) lets any authenticated user read the full profile of every other +-- user, including the email column. The legacy public.users compat table +-- (which also stores email) has an equally permissive SELECT policy. The app +-- never needs another user's email: the only client code that fetched it was +-- selecting "*" from profiles. +-- +-- Fix: +-- 1. Revoke column-level SELECT on profiles.email from anon + authenticated. +-- Only the row owner (via their own profile select) and the service role +-- (backend) can read email addresses. +-- 2. Drop the permissive SELECT policy on the legacy public.users table and +-- revoke table-level SELECT from anon + authenticated so it cannot be used +-- as an alternative email-exposure path. +-- +-- NOTE: Column-level REVOKE means client queries that SELECT "*" from profiles +-- will fail, so the frontend was updated to select only the non-sensitive +-- columns it renders (see useMessages.ts, Profile.tsx, EditProfile.tsx). + +-- 1. profiles.email is PII: only the row owner (own-row select) and +-- service_role may read it. +REVOKE SELECT (email) ON public.profiles FROM anon, authenticated; + +-- 2. Legacy public.users compat table: no client role has a legitimate reason +-- to read it. Drop the permissive policy and revoke SELECT entirely. +DROP POLICY IF EXISTS "Authenticated users can view users" ON public.users; +DROP POLICY IF EXISTS "Users can insert own user row" ON public.users; +DROP POLICY IF EXISTS "Users can update own user row" ON public.users; + +REVOKE ALL ON public.users FROM anon, authenticated; + +-- Document the security model on the profiles table. +COMMENT ON TABLE public.profiles IS + 'User profiles with personal information. + Security: Authenticated users can view profile rows, but the email column is + only readable by the row owner and the service role (REVOKE SELECT (email)). + Anonymous users cannot access any profile data.';