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.';