From a6ce34e5a687f8326cc6ec046a836ec6b20f7bc4 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto Date: Sat, 29 Aug 2026 12:20:13 +0000 Subject: [PATCH] fix(messages,privacy): stop the People Picker from republishing email-derived handles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visitor feedback on /messages: the New Message picker's default suggestion list showed accounts like "georgy.butaev+ocauth1 @georgy.butaev+ocauth1" — real email local-parts published as public handles, visible to any logged-in user. This is the leak fixed at the two known signup paths in 20260826130000_stop_deriving_usernames_from_email.sql and ProfileServerService.ensureProfile() — but not every account is through the (deliberately manual, not-automatic) backfill script yet, and two more write paths still minted the same leak independently: - scripts/db/setup-db.ts hardcodes a sign-in as butaeff@gmail.com and upserts username: email.split('@')[0] on every run — almost certainly the actual source of the "+ocauth1"/"+ocauth2" test accounts in the report (butaeff is one of them). - scripts/db/reset-user-password.js upserts username: email verbatim (the full address, not just the local part) for its fixture account. Both now use the same neutral-username shape as the fixed paths (neutralUsernameFor in the .ts script; the equivalent inlined in the plain .js one, which can't import it). For accounts already leaked and not yet backfilled: added isEmailDerivedHandle() (src/config/public-directory.ts, same predicate and .invalid exception as the SQL side's count_email_derived_usernames()) and wired it into GET /api/profiles to hide a still-leaking handle from the *default* (no search term) suggestion list — the surface this was reported from. An explicit search still finds the person; this isn't a substitute for renaming their handle, which needs scripts/rename-email-derived-usernames.sql run against production by someone with DB access (noted in the session handoff, not in scope for this dispatch — no live Supabase credentials in this sandbox). Also: NewConversationModal's picker row showed name and @username duplicated whenever a profile has no display name (title already falls back to the handle) — pure noise, worse on narrow screens where both lines truncate. Now the @handle line only renders when it adds information beyond the title. --- .../unit/config/public-directory.test.ts | 33 ++++++++++ scripts/db/reset-user-password.js | 64 +++++++++---------- scripts/db/setup-db.ts | 49 +++++++------- src/app/api/profiles/route.ts | 16 ++++- .../messaging/NewConversationModal.tsx | 6 +- src/config/public-directory.ts | 27 ++++++++ 6 files changed, 136 insertions(+), 59 deletions(-) diff --git a/__tests__/unit/config/public-directory.test.ts b/__tests__/unit/config/public-directory.test.ts index 91afc069d..a7d0a1ef5 100644 --- a/__tests__/unit/config/public-directory.test.ts +++ b/__tests__/unit/config/public-directory.test.ts @@ -3,6 +3,7 @@ import { isFixtureDisplayName, isFixtureProfile, isFixtureGroupTitle, + isEmailDerivedHandle, } from '@/config/public-directory'; describe('public-directory', () => { @@ -48,4 +49,36 @@ describe('public-directory', () => { expect(isFixtureProfile({ username: 'adelina1996gry', name: 'E2E Reset User' })).toBe(true); expect(isFixtureProfile({ username: 'adelina1996gry', name: 'Adelina' })).toBe(false); }); + + // Reported from the New Message picker's default suggestion list: an + // account not yet through scripts/rename-email-derived-usernames.sql + // still shows its owner's email local part as a public, crawlable handle. + it('flags a handle that still republishes its owner email', () => { + expect( + isEmailDerivedHandle({ + username: 'georgy.butaev+ocauth1', + email: 'georgy.butaev+ocauth1@gmail.com', + }) + ).toBe(true); + expect(isEmailDerivedHandle({ username: 'mao', email: 'georgy.butaev@orangecat.ch' })).toBe( + false + ); + }); + + it('is case-insensitive, since email and username casing can drift independently', () => { + expect(isEmailDerivedHandle({ username: 'Mao', email: 'MAO@orangecat.ch' })).toBe(true); + }); + + // .invalid (RFC 2606) has no mailbox, so no owner and no personal + // information in its local part — the exception that stopped a rename + // script from retiring the Cat's own handle. See + // 20260828070000_system_accounts_keep_their_handles.sql. + it('never flags a system account on a .invalid address', () => { + expect(isEmailDerivedHandle({ username: 'cat', email: 'cat@orangecat.invalid' })).toBe(false); + }); + + it('is false with nothing to compare', () => { + expect(isEmailDerivedHandle({ username: 'mao', email: null })).toBe(false); + expect(isEmailDerivedHandle({ username: null, email: 'mao@orangecat.ch' })).toBe(false); + }); }); diff --git a/scripts/db/reset-user-password.js b/scripts/db/reset-user-password.js index a44c4a31a..363f1d61b 100644 --- a/scripts/db/reset-user-password.js +++ b/scripts/db/reset-user-password.js @@ -13,99 +13,99 @@ if (!supabaseUrl || !supabaseServiceKey) { const supabase = createClient(supabaseUrl, supabaseServiceKey, { auth: { autoRefreshToken: false, - persistSession: false - } + persistSession: false, + }, }); async function resetUserPassword() { const email = 'test@orangecat.ch'; const newPassword = 'TestPassword123!'; - + try { console.log('🔧 Resetting user password...'); - + // Method 1: Update user password using admin API const { data: user, error: updateError } = await supabase.auth.admin.updateUserById( 'e954df8f-6a9b-4cb1-8c3f-8518dc78f846', { password: newPassword } ); - + if (updateError) { console.error('❌ Error updating password:', updateError.message); - + // Method 2: Delete and recreate user console.log('🔄 Trying to delete and recreate user...'); - + // Delete user const { error: deleteError } = await supabase.auth.admin.deleteUser( 'e954df8f-6a9b-4cb1-8c3f-8518dc78f846' ); - + if (deleteError && !deleteError.message.includes('User not found')) { console.error('❌ Error deleting user:', deleteError.message); } - + // Create new user const { data: newUser, error: createError } = await supabase.auth.admin.createUser({ email: email, password: newPassword, - email_confirm: true + email_confirm: true, }); - + if (createError) { console.error('❌ Error creating user:', createError.message); return; } - + console.log('✅ User recreated successfully'); console.log('User ID:', newUser.user.id); - + // Create/update profile - const { error: profileError } = await supabase - .from('profiles') - .upsert({ - id: newUser.user.id, - username: email, - full_name: 'Test User', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - }); - + const { error: profileError } = await supabase.from('profiles').upsert({ + id: newUser.user.id, + // Never the raw email — a public, crawlable handle republishing + // someone's address is the exact leak fixed in + // supabase/migrations/20260826130000_stop_deriving_usernames_from_email.sql. + // Mirrors src/lib/profile/neutral-username.ts (plain JS script, can't import it). + username: 'user_' + newUser.user.id.replace(/-/g, '').slice(0, 12), + full_name: 'Test User', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + if (profileError) { console.error('❌ Error creating profile:', profileError.message); } else { console.log('✅ Profile created successfully'); } - } else { console.log('✅ Password updated successfully'); console.log('User ID:', user.user.id); } - + // Test authentication console.log('🧪 Testing authentication...'); - + // Create a new client instance for testing const testClient = createClient(supabaseUrl, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY); - + const { data: signInData, error: signInError } = await testClient.auth.signInWithPassword({ email: email, - password: newPassword + password: newPassword, }); - + if (signInError) { console.error('❌ Authentication test failed:', signInError.message); } else { console.log('✅ Authentication test successful!'); console.log('Authenticated user:', signInData.user.email); - + // Sign out to clean up await testClient.auth.signOut(); } - } catch (error) { console.error('❌ Unexpected error:', error.message); } } -resetUserPassword(); \ No newline at end of file +resetUserPassword(); diff --git a/scripts/db/setup-db.ts b/scripts/db/setup-db.ts index 59e53e95d..27de94f2f 100644 --- a/scripts/db/setup-db.ts +++ b/scripts/db/setup-db.ts @@ -1,10 +1,11 @@ -import 'dotenv/config' -import { createClient } from '@supabase/supabase-js' +import 'dotenv/config'; +import { createClient } from '@supabase/supabase-js'; +import { neutralUsernameFor } from '@/lib/profile/neutral-username'; -const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL! -const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; -const supabase = createClient(supabaseUrl, supabaseKey) +const supabase = createClient(supabaseUrl, supabaseKey); async function setupDatabase() { try { @@ -13,33 +14,35 @@ async function setupDatabase() { // Sign in const { error: signInError } = await supabase.auth.signInWithPassword({ email: 'butaeff@gmail.com', - password: process.env.USER_PASSWORD || 'your-password' - }) - if (signInError) throw signInError - if (process.env.NODE_ENV === 'development') console.log('✅ Signed in') + password: process.env.USER_PASSWORD || 'your-password', + }); + if (signInError) throw signInError; + if (process.env.NODE_ENV === 'development') console.log('✅ Signed in'); // Create initial profile - const { data: { user }, error: userError } = await supabase.auth.getUser() - if (userError) throw userError + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + if (userError) throw userError; if (user) { - const { error: profileError } = await supabase - .from('profiles') - .upsert({ - id: user.id, - username: user.email?.split('@')[0] || 'user', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - }) - if (profileError) throw profileError + const { error: profileError } = await supabase.from('profiles').upsert({ + id: user.id, + // Never the email local part — see src/lib/profile/neutral-username.ts. + username: neutralUsernameFor(user.id), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + if (profileError) throw profileError; // REMOVED: console.log statement } // REMOVED: console.log statement } catch (error) { - console.error('Error setting up database:', error) - process.exit(1) + console.error('Error setting up database:', error); + process.exit(1); } } -setupDatabase() \ No newline at end of file +setupDatabase(); diff --git a/src/app/api/profiles/route.ts b/src/app/api/profiles/route.ts index ee5e1cbc2..4c9e2fe2d 100644 --- a/src/app/api/profiles/route.ts +++ b/src/app/api/profiles/route.ts @@ -15,7 +15,7 @@ export const GET = withAuth(async (request: AuthenticatedRequest) => { let query = supabase .from(DATABASE_TABLES.PROFILES) .select( - `id, username, name, bio, avatar_url, bitcoin_address, lightning_address, created_at, updated_at`, + `id, username, name, bio, avatar_url, bitcoin_address, lightning_address, created_at, updated_at, email`, { count: 'exact' } ) .order('created_at', { ascending: false }) @@ -38,8 +38,18 @@ export const GET = withAuth(async (request: AuthenticatedRequest) => { throw error; } - const { isFixtureProfile } = await import('@/config/public-directory'); - const profiles = (data || []).filter(p => !isFixtureProfile(p)); + const { isFixtureProfile, isEmailDerivedHandle } = await import('@/config/public-directory'); + const rows = (data || []).filter(p => !isFixtureProfile(p)); + // Defense in depth for accounts not yet through the (manual, deliberate — + // see scripts/rename-email-derived-usernames.sql) backfill: keep them out + // of the unprompted default suggestion list, the surface this was + // reported from. An explicit search still finds them — hiding a real + // person from someone typing their exact name would be an availability + // regression, not a privacy fix. + const filtered = search ? rows : rows.filter(p => !isEmailDerivedHandle(p)); + // Never ship the email column to the client — it was only selected for + // the filter above. + const profiles = filtered.map(({ email: _email, ...rest }) => rest); return apiSuccessPaginated(profiles, page, limit, count ?? profiles.length); } catch (error) { return handleApiError(error); diff --git a/src/components/messaging/NewConversationModal.tsx b/src/components/messaging/NewConversationModal.tsx index 3d7fd1d66..6380f620c 100644 --- a/src/components/messaging/NewConversationModal.tsx +++ b/src/components/messaging/NewConversationModal.tsx @@ -249,7 +249,11 @@ export default function NewConversationModal({
{p.name || p.username || 'User'}
- {p.username && ( + {/* Only when the handle adds information beyond the title above — + a profile with no name already shows its handle there, so + repeating "@handle" underneath is pure noise, worse on + narrow screens where both lines truncate. */} + {p.username && p.name && p.name !== p.username && (
@{p.username}
)} diff --git a/src/config/public-directory.ts b/src/config/public-directory.ts index b0e462025..b4c6cccb4 100644 --- a/src/config/public-directory.ts +++ b/src/config/public-directory.ts @@ -106,3 +106,30 @@ export function isFixtureGroupTitle(title: string | null | undefined): boolean { const t = (title ?? '').trim(); return FIXTURE_GROUP_TITLE.test(t) || FIXTURE_GROUP_STAMPED.test(t); } + +/** + * True when a profile's public handle still republishes its owner's email + * local part — the leak fixed at the write path in + * supabase/migrations/20260826130000_stop_deriving_usernames_from_email.sql + * and backfilled by scripts/rename-email-derived-usernames.sql, but not every + * affected row has been through that backfill yet (it's a deliberate, + * checked-by-hand operation, not something a schema migration runs + * automatically — see that script's own header for why). + * + * Same predicate as the SQL side's `count_email_derived_usernames()`, + * including the `.invalid` exception (RFC 2606 — an undeliverable address has + * no mailbox, so no owner, so no personal information in its local part; see + * 20260828070000_system_accounts_keep_their_handles.sql for the incident that + * taught us to carry the exception here too). + */ +export function isEmailDerivedHandle(profile: { + username?: string | null; + email?: string | null; +}): boolean { + const email = (profile.email ?? '').trim().toLowerCase(); + if (!email || email.endsWith('.invalid')) { + return false; + } + const localPart = email.split('@')[0]; + return !!localPart && (profile.username ?? '').trim().toLowerCase() === localPart; +}