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
3 changes: 1 addition & 2 deletions src/components/messages/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ export function Sidebar({
return conversationSummaries.filter(({ profile, lastMessage }) => {
const haystack = [
getDisplayName(profile),
profile.email ?? "",
getRoleLabel(profile),
lastMessage ? getMessageBody(lastMessage) : "",
]
Expand All @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/components/messages/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { ProfileSummary, MessageRow } from "@/hooks/useMessages";

export const getDisplayName = (profile?: Pick<ProfileSummary, "name" | "email"> | null) =>
profile?.name?.trim() || profile?.email?.split("@")[0] || "Learner";
export const getDisplayName = (profile?: Pick<ProfileSummary, "name"> | null) =>
profile?.name?.trim() || "Learner";

export const getInitial = (profile?: Pick<ProfileSummary, "name" | "email"> | null) =>
export const getInitial = (profile?: Pick<ProfileSummary, "name"> | null) =>
getDisplayName(profile).charAt(0).toUpperCase();

export const getMessageBody = (message: MessageRow) =>
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/useMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 5 additions & 4 deletions src/pages/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ const MarkdownRenderer = React.lazy(() =>
type Profile = {
id: string;
name: string | null;
email: string | null;
avatar_url?: string | null;
};

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/pages/EditProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
4 changes: 3 additions & 1 deletion src/pages/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
40 changes: 40 additions & 0 deletions supabase/migrations/20260805000001_protect_profiles_email_rls.sql
Original file line number Diff line number Diff line change
@@ -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.';
Loading