Skip to content

Commit 943bf19

Browse files
ralyodioclaude
andauthored
fix(db): re-lock SECURITY DEFINER functions whose revokes were lost in the dev2 move (#341)
All 53 SECURITY DEFINER functions in public were executable by the anon key again (the dev2 restore dropped the #247 ACLs). Because the #247 bodies treat a NULL auth.uid() as the trusted server, anon walked through every check, e.g. upsert_user_public_key could replace any user's public key. Re-asserts the #247 grant matrix (bodies unchanged), pins users.account_type and operator_user_id (anonymous -> verified self-upgrade unlocked free phone minutes), locks blog_posts_bak_20261014, scopes the 'system' insert policies to service_role, and stops new functions defaulting to PUBLIC/anon execute. Applied by hand on dev2. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6ce6644 commit 943bf19

1 file changed

Lines changed: 164 additions & 0 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
-- Re-lock the SECURITY DEFINER functions: their EXECUTE revokes were lost in the dev2 move.
2+
--
3+
-- 20260814030009, 20260814032259 and 20260814030037/032942 (#247) revoked EXECUTE on every
4+
-- SECURITY DEFINER function in schema public from anon (and, for internal ones, from
5+
-- authenticated) and bound the user-callable ones to auth.uid(). The bodies survived the
6+
-- move to the self-hosted dev2 stack, but the ACLs did not (the restore did not carry
7+
-- privileges), so on 2026-10-26 all 53 SECURITY DEFINER functions were executable by the
8+
-- public anon key again.
9+
--
10+
-- That matters more than it looks, because the #247 bodies treat `auth.uid() IS NULL` as
11+
-- "trusted server caller" (the service role has no uid). With anon allowed back in, anon
12+
-- also has no uid, so every one of those checks waved anon through. The worst cases:
13+
-- * upsert_user_public_key(target, key): `IF auth.uid() != target` is NULL for anon, so the
14+
-- anon key could REPLACE ANY USER'S PUBLIC KEY (silent man-in-the-middle of E2EE).
15+
-- * fn_get_user_message_content(msg, user): another user's encrypted message envelope.
16+
-- * get_user_call_sessions / fn_get_user_active_deliveries / get_user_call_history /
17+
-- get_user_active_calls: anyone's call sessions, deliveries and call history.
18+
-- * get_conversations_with_archive_* / get_user_archived_conversations: anyone's
19+
-- conversation list. get_sms_stats, log_sms_event, cleanup_*: internal.
20+
-- * sync_user_with_auth / fix_* / update_canonical_auth_user_ids: rewrite identity rows.
21+
--
22+
-- Function BODIES are unchanged here (messaging behaviour stays identical). Only grants:
23+
-- the same matrix #247 shipped, re-asserted idempotently, plus fn_cleanup_expired_messages
24+
-- moved to service_role only (the app only calls it with the service role).
25+
--
26+
-- Also in this migration (rule: users must not write privileged columns of their own row):
27+
-- * users.account_type and users.operator_user_id join the immutable list. An
28+
-- 'anonymous' account could PATCH itself to 'verified' and get the free daily phone
29+
-- minutes that src/lib/server/phone.js withholds from anonymous accounts.
30+
-- * blog_posts_bak_20261014 had RLS off and full anon/authenticated grants.
31+
-- * key_access_log / sms_notifications INSERT policies `WITH CHECK (auth.uid() IS NULL)`
32+
-- were meant for "the system" but matched anon; the service role bypasses RLS anyway,
33+
-- so they now apply to service_role only.
34+
-- * Default privileges: new functions are no longer executable by PUBLIC or anon.
35+
36+
-- 1. Internal / server-only: service_role only.
37+
DO $$
38+
DECLARE
39+
fn text;
40+
sig text;
41+
names text[] := ARRAY[
42+
'backfill_all_message_status','bump_autoblog_integration','calculate_message_expiration',
43+
'check_message_status_coverage','check_sms_rate_limit','cleanup_expired_messages',
44+
'cleanup_sms_audit_logs','cleanup_sms_rate_limits','delete_encrypted_data_only',
45+
'enforce_users_immutable_columns','fix_canonical_auth_user_ids',
46+
'fix_missing_conversation_participants','fn_cleanup_expired_messages',
47+
'fn_create_message_recipients','fn_create_message_status_entries',
48+
'fn_get_user_message_content','fn_messages_ready_for_gc',
49+
'get_conversations_with_archive_data','get_conversations_with_archive_support',
50+
'get_sms_stats','get_storage_bucket_limits','get_user_archived_conversations',
51+
'get_user_call_sessions','is_user_inactive','log_otp_verification_attempt',
52+
'log_sms_event','nuclear_delete_user_data','set_message_expiration','set_user_offline',
53+
'sync_user_with_auth','trigger_create_message_status','update_canonical_auth_user_ids',
54+
'update_message_has_files'
55+
];
56+
BEGIN
57+
FOREACH fn IN ARRAY names LOOP
58+
FOR sig IN
59+
SELECT format('public.%I(%s)', p.proname, pg_get_function_identity_arguments(p.oid))
60+
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
61+
WHERE n.nspname = 'public' AND p.proname = fn AND p.prosecdef
62+
LOOP
63+
EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC, anon, authenticated', sig);
64+
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', sig);
65+
END LOOP;
66+
END LOOP;
67+
END $$;
68+
69+
-- 2. Signed-in users call these (browser, websocket handlers, user-session server
70+
-- clients, RLS policies). Each scopes to auth.uid(); none may be called by anon.
71+
DO $$
72+
DECLARE
73+
fn text;
74+
sig text;
75+
names text[] := ARRAY[
76+
'archive_conversation','unarchive_conversation',
77+
'create_call_session','establish_call_session',
78+
'find_user_by_unique_identifier','fn_create_deliveries_for_message',
79+
'fn_get_user_active_deliveries','fn_mark_message_read','fn_mark_read',
80+
'log_sms_notification','update_user_activity','get_user_public_key',
81+
'upsert_user_public_key','get_inactive_participants','get_user_call_history',
82+
'get_user_active_calls','current_app_user_id','is_conversation_creator',
83+
'is_conversation_participant','shares_conversation_with'
84+
];
85+
BEGIN
86+
FOREACH fn IN ARRAY names LOOP
87+
FOR sig IN
88+
SELECT format('public.%I(%s)', p.proname, pg_get_function_identity_arguments(p.oid))
89+
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
90+
WHERE n.nspname = 'public' AND p.proname = fn AND p.prosecdef
91+
LOOP
92+
EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC, anon', sig);
93+
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO authenticated, service_role', sig);
94+
END LOOP;
95+
END LOOP;
96+
END $$;
97+
98+
-- 3. Any SECURITY DEFINER function in public not named above: lock it to service_role too,
99+
-- so a function added later without a grant review is not left open by this list.
100+
DO $$
101+
DECLARE
102+
sig text;
103+
BEGIN
104+
FOR sig IN
105+
SELECT format('public.%I(%s)', p.proname, pg_get_function_identity_arguments(p.oid))
106+
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
107+
WHERE n.nspname = 'public' AND p.prosecdef
108+
AND has_function_privilege('anon', p.oid, 'EXECUTE')
109+
LOOP
110+
EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC, anon, authenticated', sig);
111+
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', sig);
112+
END LOOP;
113+
END $$;
114+
115+
-- 4. New functions: not executable by PUBLIC or anon unless granted on purpose.
116+
ALTER DEFAULT PRIVILEGES FOR ROLE postgres REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
117+
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM anon;
118+
119+
-- 5. users: account_type and operator_user_id are server-set, like the identity columns.
120+
CREATE OR REPLACE FUNCTION public.enforce_users_immutable_columns()
121+
RETURNS trigger
122+
LANGUAGE plpgsql
123+
SECURITY DEFINER
124+
SET search_path TO ''
125+
AS $function$
126+
BEGIN
127+
IF current_setting('role', true) = 'service_role'
128+
OR auth.uid() IS NULL THEN
129+
RETURN NEW;
130+
END IF;
131+
132+
IF NEW.id IS DISTINCT FROM OLD.id
133+
OR NEW.auth_user_id IS DISTINCT FROM OLD.auth_user_id
134+
OR NEW.phone_number IS DISTINCT FROM OLD.phone_number
135+
OR NEW.unique_identifier IS DISTINCT FROM OLD.unique_identifier
136+
OR NEW.created_at IS DISTINCT FROM OLD.created_at
137+
OR NEW.salt IS DISTINCT FROM OLD.salt
138+
OR NEW.account_type IS DISTINCT FROM OLD.account_type
139+
OR NEW.operator_user_id IS DISTINCT FROM OLD.operator_user_id
140+
THEN
141+
RAISE EXCEPTION
142+
'Identity columns of users are not client-writable (id, auth_user_id, phone_number, unique_identifier, created_at, salt, account_type, operator_user_id)';
143+
END IF;
144+
145+
RETURN NEW;
146+
END;
147+
$function$;
148+
149+
REVOKE EXECUTE ON FUNCTION public.enforce_users_immutable_columns() FROM PUBLIC, anon, authenticated;
150+
151+
-- 6. Leftover backup table with RLS off.
152+
ALTER TABLE IF EXISTS public.blog_posts_bak_20261014 ENABLE ROW LEVEL SECURITY;
153+
REVOKE ALL ON TABLE public.blog_posts_bak_20261014 FROM anon, authenticated;
154+
155+
-- 7. "System" insert policies that matched anon.
156+
DROP POLICY IF EXISTS "System can insert key access logs" ON public.key_access_log;
157+
CREATE POLICY "System can insert key access logs" ON public.key_access_log
158+
FOR INSERT TO service_role WITH CHECK (true);
159+
160+
DROP POLICY IF EXISTS "System can insert SMS notifications" ON public.sms_notifications;
161+
CREATE POLICY "System can insert SMS notifications" ON public.sms_notifications
162+
FOR INSERT TO service_role WITH CHECK (true);
163+
164+
NOTIFY pgrst, 'reload schema';

0 commit comments

Comments
 (0)