From afaa900c0f9ca1b08f7d7483f19126ed2850b6cc Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:46:00 +0200 Subject: [PATCH] fix(profiles): stop minting public handles out of people's email addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle_new_user() set a new profile's PUBLIC username to split_part(NEW.email, '@', 1), and fell back to the same value for the display name. /profiles/ is served to anyone with no auth and robots.txt has no /profiles rule, so every signup published its owner's email local part as a crawlable handle. With a handful of common domains that reconstructs the address. Measured on production before this change: 72 of 99 profiles have username = split_part(email, '@', 1), and /profiles/mao returns 200 unauthenticated. It was reported through OrangeCat's own feedback widget ("the New Message people picker exposes email local-parts as public usernames") and sat unactioned in the Control queue. 20260818140000 already fixed the worse half — usernames that were the FULL address — and added a CHECK forbidding '@'. It left the local-part derivation in place, so this is the same leak one character shorter. Two forward-only changes: username now derived from the user's id, which carries no personal information. Users still pick their own via PUT /api/profile. name no longer falls back to the email local part. NULL is honest — the UI already falls back to the username — whereas a display name quietly set to someone's email prefix is the same leak wearing a different label. Also fixes a latent signup failure: username is NOT NULL with a unique index, and ON CONFLICT (id) DO NOTHING does not catch a username collision, so two users sharing a local part across domains (mao@a.com, mao@b.com) meant the second signup raised. An id-derived handle cannot collide. EXISTING ROWS ARE NOT RENAMED, deliberately. A username is also a Lightning address (@orangecat.ch — see .well-known/lnurlp and /api/lnurlp//callback) and a public profile URL. Renaming the 72 affected accounts would break saved payment addresses and inbound links for real people. That is a product decision with its own migration path (opt-in rename, alias retained for lnurlp, 301 on the old profile URL), not something a schema migration should smuggle in. The detection half ships with it. count_email_derived_usernames() is a service_role-only SECURITY DEFINER counter (auth.users is not reachable through PostgREST), returning a COUNT and never rows — a list of "profiles whose handle is their email prefix" is exactly what we are trying not to publish. check-data-invariants.mjs gates on it as a RATCHET against the known 72: it may fall or hold, never rise. A zero-check would be red every night about code that is fine, which is how a fleet learns to ignore its own gates. npm run verify green. Co-Authored-By: Claude Opus 5 --- scripts/check-data-invariants.mjs | 41 ++++++++ ...000_stop_deriving_usernames_from_email.sql | 97 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 supabase/migrations/20260826130000_stop_deriving_usernames_from_email.sql diff --git a/scripts/check-data-invariants.mjs b/scripts/check-data-invariants.mjs index d4e8f1447..809ce0ca6 100644 --- a/scripts/check-data-invariants.mjs +++ b/scripts/check-data-invariants.mjs @@ -272,6 +272,46 @@ async function checkSilentlyDroppedCatTurns() { * auth.users is not exposed through PostgREST, so this goes through the * service_role-only count_orphaned_profiles() function. */ +/** + * How many profiles still publish their email local part as a public handle. + * + * `/profiles/` is served with no auth and robots.txt has no + * /profiles rule, so such a handle is crawlable; with a handful of common + * domains it reconstructs the address. handle_new_user() minted them until + * 20260826130000. + * + * A RATCHET, not a zero-check. The 72 accounts that already have one are NOT + * renamed: a username is also a Lightning address + * (`@orangecat.ch`) and a public profile URL, so renaming breaks + * saved payment addresses and inbound links for real people. A gate demanding + * zero would therefore be red every night about code that is fine — the exact + * habit that teaches everyone to ignore the gate. This fails only if the + * number RISES, which means a new write path started deriving handles from + * emails again. + */ +const EMAIL_DERIVED_USERNAME_BASELINE = 72; // measured on production 2026-08-26 + +async function checkEmailDerivedUsernames() { + const count = Number(await rpc('count_email_derived_usernames')); + + if (count > EMAIL_DERIVED_USERNAME_BASELINE) { + violation( + 'profiles.username_from_email', + `${count} profiles publish their email local part as a public, crawlable handle — ` + + `up from the ${EMAIL_DERIVED_USERNAME_BASELINE} known on 2026-08-26, so a write path ` + + `is minting them again (handle_new_user, a script, or a manual fix)`, + [] + ); + } else if (count < EMAIL_DERIVED_USERNAME_BASELINE) { + notes.push( + `profiles: ${count} email-derived handles left (was ${EMAIL_DERIVED_USERNAME_BASELINE}) — ` + + `lower EMAIL_DERIVED_USERNAME_BASELINE so the ratchet holds the new floor` + ); + } else { + notes.push(`profiles: ${count} email-derived handles, unchanged — no new ones minted`); + } +} + async function checkOrphanedProfiles() { const count = Number(await rpc('count_orphaned_profiles')); @@ -403,6 +443,7 @@ async function main() { checkPaidWithoutTimestamp, checkSilentlyDroppedCatTurns, checkOrphanedProfiles, + checkEmailDerivedUsernames, checkOrphanedCatConversations, checkOrphanedActors, ]; diff --git a/supabase/migrations/20260826130000_stop_deriving_usernames_from_email.sql b/supabase/migrations/20260826130000_stop_deriving_usernames_from_email.sql new file mode 100644 index 000000000..5db38b9a9 --- /dev/null +++ b/supabase/migrations/20260826130000_stop_deriving_usernames_from_email.sql @@ -0,0 +1,97 @@ +-- Stop minting public identities out of people's email addresses. +-- +-- `handle_new_user()` set a new profile's PUBLIC username to +-- split_part(NEW.email, '@', 1), and fell back to the same value for the +-- display name. `/profiles/` is served to anyone with no auth and +-- robots.txt has no /profiles rule, so every signup published its owner's +-- email local part as a crawlable handle. With a handful of common domains +-- that reconstructs the address. +-- +-- Measured on production 2026-08-26 before this migration: 72 of 99 profiles +-- had `username = split_part(email, '@', 1)`, and the message picker's default +-- suggestion list showed them to any logged-in user — which is how it was +-- reported. +-- +-- 20260818140000 already fixed the worse half (usernames that were the FULL +-- address) and added a CHECK forbidding '@'. It left the local-part derivation +-- in place, so this is the same leak one character shorter. +-- +-- Two changes, both forward-only: +-- +-- 1. username is now derived from the user's id, which carries no personal +-- information. Users can still pick their own via PUT /api/profile. +-- 2. name no longer falls back to the email local part. NULL is honest — +-- the UI already falls back to the username — whereas a display name +-- quietly set to someone's email prefix is the same leak wearing a +-- different label. +-- +-- EXISTING ROWS ARE NOT RENAMED, deliberately. A username is also a Lightning +-- address (`@orangecat.ch`, see .well-known/lnurlp and +-- /api/lnurlp//callback) and a public profile URL. Renaming the 72 +-- affected accounts would break saved payment addresses and inbound links for +-- real people. That is a product decision with a migration path (opt-in +-- rename + alias retained for lnurlp + 301 on the old profile URL), not +-- something a schema migration should smuggle in. +-- +-- Fixes a latent signup failure too: username is NOT NULL with a unique index, +-- and `ON CONFLICT (id) DO NOTHING` does not catch a username collision. Two +-- users with the same local part at different domains (mao@a.com, mao@b.com) +-- meant the second signup raised. An id-derived handle cannot collide. + +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $function$ +BEGIN + INSERT INTO public.profiles (id, username, name, email, status, created_at, updated_at) + VALUES ( + NEW.id, + -- 12 hex chars of the uuid: unique by construction, and it says nothing + -- about who the person is. Matches the app's own username pattern + -- (^[a-zA-Z0-9_-]+$, src/lib/validation/base.ts). + 'user_' || left(replace(NEW.id::text, '-', ''), 12), + -- OAuth display name if the provider gave one, else NULL. No email + -- fallback: see the note above. + NULLIF(COALESCE( + NEW.raw_user_meta_data->>'full_name', + NEW.raw_user_meta_data->>'name' + ), ''), + NEW.email, + 'active', + NOW(), + NOW() + ) + ON CONFLICT (id) DO NOTHING; + RETURN NEW; +END; +$function$; + +-- The detection half, so this cannot regress unnoticed. +-- +-- auth.users is not reachable through PostgREST, so the nightly invariant gate +-- (scripts/check-data-invariants.mjs) cannot see an email-derived handle by +-- querying tables — it needs this. Same shape as count_orphaned_profiles: +-- SECURITY DEFINER to read the auth schema, locked to service_role so nothing +-- user-facing can enumerate it, and it returns a COUNT rather than rows — +-- a list of "profiles whose handle is their email prefix" is precisely the +-- thing we are trying not to publish. +CREATE OR REPLACE FUNCTION public.count_email_derived_usernames() +RETURNS bigint +LANGUAGE sql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ + SELECT count(*)::bigint + FROM public.profiles p + JOIN auth.users u ON u.id = p.id + WHERE p.username = split_part(u.email, '@', 1); +$$; + +REVOKE ALL ON FUNCTION public.count_email_derived_usernames() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.count_email_derived_usernames() FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION public.count_email_derived_usernames() TO service_role; + +COMMENT ON FUNCTION public.count_email_derived_usernames() IS + 'How many profiles still publish their email local part as a public handle. A ratchet for check-data-invariants.mjs: it may fall or hold, never rise.';