From 5bd84e51b0fb83d0ae07b7dffc22f328bbc7358c Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Thu, 20 Aug 2026 13:05:30 +0200 Subject: [PATCH 1/8] chore: set search path in migration --- ...0001_rescope_pg_graphql_access_trigger.sql | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql diff --git a/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql b/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql new file mode 100644 index 0000000000..ce9b5349f9 --- /dev/null +++ b/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql @@ -0,0 +1,48 @@ +-- migrate:up + +create or replace function extensions.grant_pg_graphql_access() + returns event_trigger + language plpgsql + set search_path to '' +as $func$ +begin + if not exists ( + select 1 + from pg_catalog.pg_event_trigger_ddl_commands() ev + join pg_catalog.pg_extension e on ev.objid = e.oid + where e.extname = 'pg_graphql' + ) then + return; + end if; + + drop function if exists graphql_public.graphql; + create or replace function graphql_public.graphql( + "operationName" text default null, + query text default null, + variables jsonb default null, + extensions jsonb default null + ) + returns jsonb + language sql + set search_path to '' + as $$ + select graphql.resolve( + query := query, + variables := coalesce(variables, '{}'), + "operationName" := "operationName", + extensions := extensions + ); + $$; + + -- Attach the wrapper to the extension so DROP EXTENSION cascades to it, + -- which in turn triggers set_graphql_placeholder to reinstall the "not enabled" stub. + alter extension pg_graphql add function graphql_public.graphql(text, text, jsonb, jsonb); + + grant usage on schema graphql to postgres, anon, authenticated, service_role; + grant execute on function graphql.resolve to postgres, anon, authenticated, service_role; + grant usage on schema graphql to postgres with grant option; + grant usage on schema graphql_public to postgres with grant option; +end; +$func$; + +-- migrate:down From 6c1aa316180e77f87b3811789cb0675a0260690f Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Tue, 25 Aug 2026 15:30:56 +0200 Subject: [PATCH 2/8] chore: defend additional functions --- ...0001_rescope_pg_graphql_access_trigger.sql | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql b/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql index ce9b5349f9..c9173d2e2a 100644 --- a/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql +++ b/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql @@ -45,4 +45,195 @@ begin end; $func$; + +CREATE OR REPLACE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger + LANGUAGE plpgsql + SET search_path to '' + AS $$ +BEGIN + IF EXISTS ( + SELECT + FROM pg_event_trigger_ddl_commands() AS ev + JOIN pg_extension AS ext + ON ev.objid = ext.oid + WHERE ext.extname = 'pg_cron' + ) + THEN + grant usage on schema cron to postgres with grant option; + + alter default privileges in schema cron grant all on tables to postgres with grant option; + alter default privileges in schema cron grant all on functions to postgres with grant option; + alter default privileges in schema cron grant all on sequences to postgres with grant option; + + alter default privileges for user supabase_admin in schema cron grant all + on sequences to postgres with grant option; + alter default privileges for user supabase_admin in schema cron grant all + on tables to postgres with grant option; + alter default privileges for user supabase_admin in schema cron grant all + on functions to postgres with grant option; + + grant all privileges on all tables in schema cron to postgres with grant option; + revoke all on table cron.job from postgres; + grant select on table cron.job to postgres with grant option; + revoke trigger on cron.job_run_details from postgres; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger + LANGUAGE plpgsql + SET search_path to '' + AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_event_trigger_ddl_commands() AS ev + JOIN pg_extension AS ext + ON ev.objid = ext.oid + WHERE ext.extname = 'pg_net' + ) + THEN + IF NOT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_functions_admin' + ) + THEN + CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; + END IF; + + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + + IF EXISTS ( + SELECT FROM pg_extension + WHERE extname = 'pg_net' + -- all versions in use on existing projects as of 2025-02-20 + -- version 0.12.0 onwards don't need these applied + AND extversion IN ('0.2', '0.6', '0.7', '0.7.1', '0.8.0', '0.10.0', '0.11.0') + ) THEN + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger + LANGUAGE plpgsql + SET search_path TO '' + AS $$ +DECLARE + cmd record; +BEGIN + FOR cmd IN SELECT * FROM pg_event_trigger_ddl_commands() + LOOP + IF cmd.command_tag IN ( + 'CREATE SCHEMA', 'ALTER SCHEMA' + , 'CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO', 'ALTER TABLE' + , 'CREATE FOREIGN TABLE', 'ALTER FOREIGN TABLE' + , 'CREATE VIEW', 'ALTER VIEW' + , 'CREATE MATERIALIZED VIEW', 'ALTER MATERIALIZED VIEW' + , 'CREATE FUNCTION', 'ALTER FUNCTION' + , 'CREATE TRIGGER' + , 'CREATE TYPE', 'ALTER TYPE' + , 'CREATE RULE' + , 'COMMENT' + ) + -- don't notify in case of CREATE TEMP table or other objects created on pg_temp + AND cmd.schema_name is distinct from 'pg_temp' + THEN + NOTIFY pgrst, 'reload schema'; + END IF; + END LOOP; +END; $$; + +CREATE OR REPLACE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger + LANGUAGE plpgsql + SET search_path TO '' + AS $$ +DECLARE + obj record; +BEGIN + FOR obj IN SELECT * FROM pg_event_trigger_dropped_objects() + LOOP + IF obj.object_type IN ( + 'schema' + , 'table' + , 'foreign table' + , 'view' + , 'materialized view' + , 'function' + , 'trigger' + , 'type' + , 'rule' + ) + AND obj.is_temporary IS false -- no pg_temp objects + THEN + NOTIFY pgrst, 'reload schema'; + END IF; + END LOOP; +END; $$; + +CREATE OR REPLACE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger + LANGUAGE plpgsql + SET search_path TO '' + AS $_$ + DECLARE + graphql_is_dropped bool; + BEGIN + graphql_is_dropped = ( + SELECT ev.schema_name = 'graphql_public' + FROM pg_event_trigger_dropped_objects() AS ev + WHERE ev.schema_name = 'graphql_public' + ); + + IF graphql_is_dropped + THEN + create or replace function graphql_public.graphql( + "operationName" text default null, + query text default null, + variables jsonb default null, + extensions jsonb default null + ) + returns jsonb + language plpgsql + set search_path to '' + as $$ + DECLARE + server_version float; + BEGIN + server_version = (SELECT (SPLIT_PART((select version()), ' ', 2))::float); + + IF server_version >= 14 THEN + RETURN jsonb_build_object( + 'errors', jsonb_build_array( + jsonb_build_object( + 'message', 'pg_graphql extension is not enabled.' + ) + ) + ); + ELSE + RETURN jsonb_build_object( + 'errors', jsonb_build_array( + jsonb_build_object( + 'message', 'pg_graphql is only available on projects running Postgres 14 onwards.' + ) + ) + ); + END IF; + END; + $$; + END IF; + + END; +$_$; -- migrate:down From 6a8b062f2480f77345d1b41191462f7f428707bb Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Mon, 31 Aug 2026 15:28:43 +0200 Subject: [PATCH 3/8] fix: regress tests --- .../20260820000001_rescope_pg_graphql_access_trigger.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql b/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql index c9173d2e2a..9b8c7ea540 100644 --- a/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql +++ b/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql @@ -75,7 +75,7 @@ BEGIN grant all privileges on all tables in schema cron to postgres with grant option; revoke all on table cron.job from postgres; grant select on table cron.job to postgres with grant option; - revoke trigger on cron.job_run_details from postgres; + revoke trigger on cron.job_run_details from postgres cascade; END IF; END; $$; From a458f16fb7bb7742067507559c591d9c54c52878 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Mon, 31 Aug 2026 15:57:54 +0200 Subject: [PATCH 4/8] chore: update base schemas --- migrations/schema-15.sql | 12 ++++++++++-- migrations/schema-17.sql | 12 ++++++++++-- migrations/schema-orioledb-17.sql | 12 ++++++++++-- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/migrations/schema-15.sql b/migrations/schema-15.sql index 5a27f2acac..eafdb3174e 100644 --- a/migrations/schema-15.sql +++ b/migrations/schema-15.sql @@ -169,6 +169,7 @@ $$; CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $$ BEGIN IF EXISTS ( @@ -214,11 +215,12 @@ COMMENT ON FUNCTION extensions.grant_pg_cron_access() IS 'Grants access to pg_cr CREATE FUNCTION extensions.grant_pg_graphql_access() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $_$ begin if not exists ( select 1 - from pg_event_trigger_ddl_commands() ev + from pg_catalog.pg_event_trigger_ddl_commands() ev join pg_catalog.pg_extension e on ev.objid = e.oid where e.extname = 'pg_graphql' ) then @@ -234,6 +236,7 @@ begin ) returns jsonb language sql + set search_path to '' as $$ select graphql.resolve( query := query, @@ -268,6 +271,7 @@ COMMENT ON FUNCTION extensions.grant_pg_graphql_access() IS 'Grants access to pg CREATE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $$ BEGIN IF EXISTS ( @@ -294,7 +298,7 @@ BEGIN WHERE extname = 'pg_net' -- all versions in use on existing projects as of 2025-02-20 -- version 0.12.0 onwards don't need these applied - AND extversion IN ('0.2', '0.6', '0.7', '0.7.1', '0.8', '0.10.0', '0.11.0') + AND extversion IN ('0.2', '0.6', '0.7', '0.7.1', '0.8.0', '0.10.0', '0.11.0') ) THEN ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; @@ -326,6 +330,7 @@ COMMENT ON FUNCTION extensions.grant_pg_net_access() IS 'Grants access to pg_net CREATE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $$ DECLARE cmd record; @@ -369,6 +374,7 @@ END; $$; CREATE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $$ DECLARE obj record; @@ -410,6 +416,7 @@ END; $$; CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $_$ DECLARE graphql_is_dropped bool; @@ -430,6 +437,7 @@ CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger ) returns jsonb language plpgsql + set search_path to '' as $$ DECLARE server_version float; diff --git a/migrations/schema-17.sql b/migrations/schema-17.sql index 7453a16e21..57af595022 100644 --- a/migrations/schema-17.sql +++ b/migrations/schema-17.sql @@ -170,6 +170,7 @@ $$; CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $$ BEGIN IF EXISTS ( @@ -215,11 +216,12 @@ COMMENT ON FUNCTION extensions.grant_pg_cron_access() IS 'Grants access to pg_cr CREATE FUNCTION extensions.grant_pg_graphql_access() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $_$ begin if not exists ( select 1 - from pg_event_trigger_ddl_commands() ev + from pg_catalog.pg_event_trigger_ddl_commands() ev join pg_catalog.pg_extension e on ev.objid = e.oid where e.extname = 'pg_graphql' ) then @@ -235,6 +237,7 @@ begin ) returns jsonb language sql + set search_path to '' as $$ select graphql.resolve( query := query, @@ -269,6 +272,7 @@ COMMENT ON FUNCTION extensions.grant_pg_graphql_access() IS 'Grants access to pg CREATE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $$ BEGIN IF EXISTS ( @@ -295,7 +299,7 @@ BEGIN WHERE extname = 'pg_net' -- all versions in use on existing projects as of 2025-02-20 -- version 0.12.0 onwards don't need these applied - AND extversion IN ('0.2', '0.6', '0.7', '0.7.1', '0.8', '0.10.0', '0.11.0') + AND extversion IN ('0.2', '0.6', '0.7', '0.7.1', '0.8.0', '0.10.0', '0.11.0') ) THEN ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; @@ -327,6 +331,7 @@ COMMENT ON FUNCTION extensions.grant_pg_net_access() IS 'Grants access to pg_net CREATE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $$ DECLARE cmd record; @@ -370,6 +375,7 @@ END; $$; CREATE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $$ DECLARE obj record; @@ -411,6 +417,7 @@ END; $$; CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $_$ DECLARE graphql_is_dropped bool; @@ -431,6 +438,7 @@ CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger ) returns jsonb language plpgsql + set search_path to '' as $$ DECLARE server_version float; diff --git a/migrations/schema-orioledb-17.sql b/migrations/schema-orioledb-17.sql index 5cdd7b1fec..01ac76d805 100644 --- a/migrations/schema-orioledb-17.sql +++ b/migrations/schema-orioledb-17.sql @@ -184,6 +184,7 @@ $$; CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $$ BEGIN IF EXISTS ( @@ -229,11 +230,12 @@ COMMENT ON FUNCTION extensions.grant_pg_cron_access() IS 'Grants access to pg_cr CREATE FUNCTION extensions.grant_pg_graphql_access() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $_$ begin if not exists ( select 1 - from pg_event_trigger_ddl_commands() ev + from pg_catalog.pg_event_trigger_ddl_commands() ev join pg_catalog.pg_extension e on ev.objid = e.oid where e.extname = 'pg_graphql' ) then @@ -249,6 +251,7 @@ begin ) returns jsonb language sql + set search_path to '' as $$ select graphql.resolve( query := query, @@ -283,6 +286,7 @@ COMMENT ON FUNCTION extensions.grant_pg_graphql_access() IS 'Grants access to pg CREATE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $$ BEGIN IF EXISTS ( @@ -309,7 +313,7 @@ BEGIN WHERE extname = 'pg_net' -- all versions in use on existing projects as of 2025-02-20 -- version 0.12.0 onwards don't need these applied - AND extversion IN ('0.2', '0.6', '0.7', '0.7.1', '0.8', '0.10.0', '0.11.0') + AND extversion IN ('0.2', '0.6', '0.7', '0.7.1', '0.8.0', '0.10.0', '0.11.0') ) THEN ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; @@ -341,6 +345,7 @@ COMMENT ON FUNCTION extensions.grant_pg_net_access() IS 'Grants access to pg_net CREATE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $$ DECLARE cmd record; @@ -384,6 +389,7 @@ END; $$; CREATE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $$ DECLARE obj record; @@ -425,6 +431,7 @@ END; $$; CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO '' AS $_$ DECLARE graphql_is_dropped bool; @@ -445,6 +452,7 @@ CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger ) returns jsonb language plpgsql + set search_path to '' as $$ DECLARE server_version float; From b201fc120e6e082cba325605dcf21574eb9efa1e Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Thu, 3 Sep 2026 10:10:31 +0200 Subject: [PATCH 5/8] fix: don't modify search_path grapql --- ...000001_rescope_pg_graphql_access_trigger.sql | 2 -- migrations/schema-15.sql | 17 +++++++---------- migrations/schema-17.sql | 17 +++++++---------- migrations/schema-orioledb-17.sql | 17 +++++++---------- 4 files changed, 21 insertions(+), 32 deletions(-) diff --git a/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql b/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql index 9b8c7ea540..e4763fd26f 100644 --- a/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql +++ b/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql @@ -24,7 +24,6 @@ begin ) returns jsonb language sql - set search_path to '' as $$ select graphql.resolve( query := query, @@ -206,7 +205,6 @@ CREATE OR REPLACE FUNCTION extensions.set_graphql_placeholder() RETURNS event_tr ) returns jsonb language plpgsql - set search_path to '' as $$ DECLARE server_version float; diff --git a/migrations/schema-15.sql b/migrations/schema-15.sql index eafdb3174e..146e9a3e4a 100644 --- a/migrations/schema-15.sql +++ b/migrations/schema-15.sql @@ -236,7 +236,6 @@ begin ) returns jsonb language sql - set search_path to '' as $$ select graphql.resolve( query := query, @@ -437,7 +436,6 @@ CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger ) returns jsonb language plpgsql - set search_path to '' as $$ DECLARE server_version float; @@ -521,13 +519,13 @@ begin raise debug 'PgBouncer auth request: %', p_usename; return query - select - rolname::text, - case when rolvaliduntil < now() - then null - else rolpassword::text - end - from pg_authid + select + rolname::text, + case when rolvaliduntil < now() + then null + else rolpassword::text + end + from pg_authid where rolname=$1 and rolcanlogin; end; $_$; @@ -847,4 +845,3 @@ CREATE EVENT TRIGGER pgrst_drop_watch ON sql_drop -- \unrestrict SupabaseTestDumpKey123 - diff --git a/migrations/schema-17.sql b/migrations/schema-17.sql index 57af595022..19306a924a 100644 --- a/migrations/schema-17.sql +++ b/migrations/schema-17.sql @@ -237,7 +237,6 @@ begin ) returns jsonb language sql - set search_path to '' as $$ select graphql.resolve( query := query, @@ -438,7 +437,6 @@ CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger ) returns jsonb language plpgsql - set search_path to '' as $$ DECLARE server_version float; @@ -522,13 +520,13 @@ begin raise debug 'PgBouncer auth request: %', p_usename; return query - select - rolname::text, - case when rolvaliduntil < now() - then null - else rolpassword::text - end - from pg_authid + select + rolname::text, + case when rolvaliduntil < now() + then null + else rolpassword::text + end + from pg_authid where rolname=$1 and rolcanlogin; end; $_$; @@ -848,4 +846,3 @@ CREATE EVENT TRIGGER pgrst_drop_watch ON sql_drop -- \unrestrict SupabaseTestDumpKey123 - diff --git a/migrations/schema-orioledb-17.sql b/migrations/schema-orioledb-17.sql index 01ac76d805..e965d1a2bd 100644 --- a/migrations/schema-orioledb-17.sql +++ b/migrations/schema-orioledb-17.sql @@ -251,7 +251,6 @@ begin ) returns jsonb language sql - set search_path to '' as $$ select graphql.resolve( query := query, @@ -452,7 +451,6 @@ CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger ) returns jsonb language plpgsql - set search_path to '' as $$ DECLARE server_version float; @@ -536,13 +534,13 @@ begin raise debug 'PgBouncer auth request: %', p_usename; return query - select - rolname::text, - case when rolvaliduntil < now() - then null - else rolpassword::text - end - from pg_authid + select + rolname::text, + case when rolvaliduntil < now() + then null + else rolpassword::text + end + from pg_authid where rolname=$1 and rolcanlogin; end; $_$; @@ -862,4 +860,3 @@ CREATE EVENT TRIGGER pgrst_drop_watch ON sql_drop -- \unrestrict SupabaseTestDumpKey123 - From 93124f08618d3a076180bba5dc0339b088868522 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Thu, 3 Sep 2026 10:53:28 +0200 Subject: [PATCH 6/8] fix: whitespace differences --- migrations/schema-15.sql | 15 ++++++++------- migrations/schema-17.sql | 15 ++++++++------- migrations/schema-orioledb-17.sql | 15 ++++++++------- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/migrations/schema-15.sql b/migrations/schema-15.sql index 146e9a3e4a..6edaefdb5f 100644 --- a/migrations/schema-15.sql +++ b/migrations/schema-15.sql @@ -519,13 +519,13 @@ begin raise debug 'PgBouncer auth request: %', p_usename; return query - select - rolname::text, - case when rolvaliduntil < now() - then null - else rolpassword::text - end - from pg_authid + select + rolname::text, + case when rolvaliduntil < now() + then null + else rolpassword::text + end + from pg_authid where rolname=$1 and rolcanlogin; end; $_$; @@ -845,3 +845,4 @@ CREATE EVENT TRIGGER pgrst_drop_watch ON sql_drop -- \unrestrict SupabaseTestDumpKey123 + diff --git a/migrations/schema-17.sql b/migrations/schema-17.sql index 19306a924a..20b217c5c1 100644 --- a/migrations/schema-17.sql +++ b/migrations/schema-17.sql @@ -520,13 +520,13 @@ begin raise debug 'PgBouncer auth request: %', p_usename; return query - select - rolname::text, - case when rolvaliduntil < now() - then null - else rolpassword::text - end - from pg_authid + select + rolname::text, + case when rolvaliduntil < now() + then null + else rolpassword::text + end + from pg_authid where rolname=$1 and rolcanlogin; end; $_$; @@ -846,3 +846,4 @@ CREATE EVENT TRIGGER pgrst_drop_watch ON sql_drop -- \unrestrict SupabaseTestDumpKey123 + diff --git a/migrations/schema-orioledb-17.sql b/migrations/schema-orioledb-17.sql index e965d1a2bd..dcd07d98ca 100644 --- a/migrations/schema-orioledb-17.sql +++ b/migrations/schema-orioledb-17.sql @@ -534,13 +534,13 @@ begin raise debug 'PgBouncer auth request: %', p_usename; return query - select - rolname::text, - case when rolvaliduntil < now() - then null - else rolpassword::text - end - from pg_authid + select + rolname::text, + case when rolvaliduntil < now() + then null + else rolpassword::text + end + from pg_authid where rolname=$1 and rolcanlogin; end; $_$; @@ -860,3 +860,4 @@ CREATE EVENT TRIGGER pgrst_drop_watch ON sql_drop -- \unrestrict SupabaseTestDumpKey123 + From 231029d01f9e73676421277269dfa251690d710a Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Fri, 16 Oct 2026 16:40:34 +0200 Subject: [PATCH 7/8] chore: ensure no regress --- ...000001_search_path_for_supa_functions.sql} | 23 +++++++- .../expected/event_trigger_search_path.out | 52 +++++++++++++++++++ nix/tests/sql/event_trigger_search_path.sql | 52 +++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) rename migrations/db/migrations/{20260820000001_rescope_pg_graphql_access_trigger.sql => 20261016000001_search_path_for_supa_functions.sql} (89%) create mode 100644 nix/tests/expected/event_trigger_search_path.out create mode 100644 nix/tests/sql/event_trigger_search_path.sql diff --git a/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql b/migrations/db/migrations/20261016000001_search_path_for_supa_functions.sql similarity index 89% rename from migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql rename to migrations/db/migrations/20261016000001_search_path_for_supa_functions.sql index e4763fd26f..ecf31e0351 100644 --- a/migrations/db/migrations/20260820000001_rescope_pg_graphql_access_trigger.sql +++ b/migrations/db/migrations/20261016000001_search_path_for_supa_functions.sql @@ -132,9 +132,18 @@ CREATE OR REPLACE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger AS $$ DECLARE cmd record; + obj_schema text; BEGIN FOR cmd IN SELECT * FROM pg_event_trigger_ddl_commands() LOOP + -- triggers and rules have no schema of their own (schema_name is NULL), + -- so use the schema of the table they belong to + obj_schema := CASE + WHEN cmd.object_type IN ('trigger', 'rule') + THEN (pg_identify_object_as_address(cmd.classid, cmd.objid, cmd.objsubid)).object_names[1] + ELSE cmd.schema_name + END; + IF cmd.command_tag IN ( 'CREATE SCHEMA', 'ALTER SCHEMA' , 'CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO', 'ALTER TABLE' @@ -148,22 +157,32 @@ BEGIN , 'COMMENT' ) -- don't notify in case of CREATE TEMP table or other objects created on pg_temp - AND cmd.schema_name is distinct from 'pg_temp' + -- also exclude any objects inside Supabase schemas + AND COALESCE(obj_schema, '') NOT IN ('pg_temp', 'auth', 'realtime', '_realtime', 'storage') THEN NOTIFY pgrst, 'reload schema'; END IF; END LOOP; END; $$; + CREATE OR REPLACE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger LANGUAGE plpgsql SET search_path TO '' AS $$ DECLARE obj record; + obj_schema text; BEGIN FOR obj IN SELECT * FROM pg_event_trigger_dropped_objects() LOOP + -- triggers and rules have no schema of their own (schema_name is NULL), + -- so use the schema of the table they belonged to + obj_schema := CASE + WHEN obj.object_type IN ('trigger', 'rule') THEN obj.address_names[1] + ELSE obj.schema_name + END; + IF obj.object_type IN ( 'schema' , 'table' @@ -176,6 +195,8 @@ BEGIN , 'rule' ) AND obj.is_temporary IS false -- no pg_temp objects + -- also exclude any objects inside Supabase schemas + AND COALESCE(obj_schema, '') NOT IN ('pg_temp', 'auth', 'realtime', '_realtime', 'storage') THEN NOTIFY pgrst, 'reload schema'; END IF; diff --git a/nix/tests/expected/event_trigger_search_path.out b/nix/tests/expected/event_trigger_search_path.out new file mode 100644 index 0000000000..9595d5a386 --- /dev/null +++ b/nix/tests/expected/event_trigger_search_path.out @@ -0,0 +1,52 @@ +-- Regression test: every Supabase-owned event trigger function must pin search_path. +-- +-- Event triggers fire as superuser on DDL run by any role, so an event trigger function +-- that resolves names through the caller's search_path can be hijacked by objects the +-- caller creates. A migration that redefines one of these functions with CREATE OR REPLACE +-- and omits `SET search_path TO ''` silently drops the pin (this happened with +-- 20260923021047_update_pgrst_watch_triggers.sql). Extension-owned event triggers are +-- excluded: their functions ship with the extension and are not ours to change. +-- Silent on success, raises on regression. +do $$ +declare + _checked int; + _offenders text; +begin + -- Event triggers are per-database. Only the pg_regress database ('postgres') has the + -- migrations applied; the pgtap pass runs this file against a bare 'testing' database. + if current_database() <> 'postgres' then + return; + end if; + + with supabase_event_triggers as ( + select e.evtname, p.oid as fn, p.proconfig + from pg_event_trigger e + join pg_proc p on p.oid = e.evtfoid + where not exists ( + select from pg_depend d + where d.deptype = 'e' + and ( + (d.classid = 'pg_event_trigger'::regclass and d.objid = e.oid) + or (d.classid = 'pg_proc'::regclass and d.objid = p.oid) + ) + ) + ) + select + count(*), + string_agg( + format('%s -> %s', evtname, fn::regprocedure), + ', ' order by evtname + ) filter (where not ('search_path=""' = any(coalesce(proconfig, '{}')))) + into _checked, _offenders + from supabase_event_triggers; + + -- guard against the query silently matching nothing + if _checked < 6 then + raise exception 'expected at least 6 Supabase event triggers, found %', _checked; + end if; + + if _offenders is not null then + raise exception 'event trigger functions without SET search_path TO '''': %', _offenders; + end if; +end +$$; diff --git a/nix/tests/sql/event_trigger_search_path.sql b/nix/tests/sql/event_trigger_search_path.sql new file mode 100644 index 0000000000..9595d5a386 --- /dev/null +++ b/nix/tests/sql/event_trigger_search_path.sql @@ -0,0 +1,52 @@ +-- Regression test: every Supabase-owned event trigger function must pin search_path. +-- +-- Event triggers fire as superuser on DDL run by any role, so an event trigger function +-- that resolves names through the caller's search_path can be hijacked by objects the +-- caller creates. A migration that redefines one of these functions with CREATE OR REPLACE +-- and omits `SET search_path TO ''` silently drops the pin (this happened with +-- 20260923021047_update_pgrst_watch_triggers.sql). Extension-owned event triggers are +-- excluded: their functions ship with the extension and are not ours to change. +-- Silent on success, raises on regression. +do $$ +declare + _checked int; + _offenders text; +begin + -- Event triggers are per-database. Only the pg_regress database ('postgres') has the + -- migrations applied; the pgtap pass runs this file against a bare 'testing' database. + if current_database() <> 'postgres' then + return; + end if; + + with supabase_event_triggers as ( + select e.evtname, p.oid as fn, p.proconfig + from pg_event_trigger e + join pg_proc p on p.oid = e.evtfoid + where not exists ( + select from pg_depend d + where d.deptype = 'e' + and ( + (d.classid = 'pg_event_trigger'::regclass and d.objid = e.oid) + or (d.classid = 'pg_proc'::regclass and d.objid = p.oid) + ) + ) + ) + select + count(*), + string_agg( + format('%s -> %s', evtname, fn::regprocedure), + ', ' order by evtname + ) filter (where not ('search_path=""' = any(coalesce(proconfig, '{}')))) + into _checked, _offenders + from supabase_event_triggers; + + -- guard against the query silently matching nothing + if _checked < 6 then + raise exception 'expected at least 6 Supabase event triggers, found %', _checked; + end if; + + if _offenders is not null then + raise exception 'event trigger functions without SET search_path TO '''': %', _offenders; + end if; +end +$$; From d3b65e590da44e2d01b980c97ebc524f22e001cd Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Fri, 16 Oct 2026 17:43:21 +0200 Subject: [PATCH 8/8] chore: protect against pg_temp --- ...61016000001_search_path_for_supa_functions.sql | 12 ++++++------ migrations/schema-15.sql | 12 ++++++------ migrations/schema-17.sql | 12 ++++++------ migrations/schema-orioledb-17.sql | 12 ++++++------ nix/tests/expected/event_trigger_search_path.out | 15 ++++++++++----- nix/tests/sql/event_trigger_search_path.sql | 15 ++++++++++----- 6 files changed, 44 insertions(+), 34 deletions(-) diff --git a/migrations/db/migrations/20261016000001_search_path_for_supa_functions.sql b/migrations/db/migrations/20261016000001_search_path_for_supa_functions.sql index ecf31e0351..286eb69867 100644 --- a/migrations/db/migrations/20261016000001_search_path_for_supa_functions.sql +++ b/migrations/db/migrations/20261016000001_search_path_for_supa_functions.sql @@ -3,7 +3,7 @@ create or replace function extensions.grant_pg_graphql_access() returns event_trigger language plpgsql - set search_path to '' + set search_path to pg_catalog, pg_temp as $func$ begin if not exists ( @@ -47,7 +47,7 @@ $func$; CREATE OR REPLACE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger LANGUAGE plpgsql - SET search_path to '' + SET search_path to pg_catalog, pg_temp AS $$ BEGIN IF EXISTS ( @@ -81,7 +81,7 @@ $$; CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger LANGUAGE plpgsql - SET search_path to '' + SET search_path to pg_catalog, pg_temp AS $$ BEGIN IF EXISTS ( @@ -128,7 +128,7 @@ $$; CREATE OR REPLACE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO pg_catalog, pg_temp AS $$ DECLARE cmd record; @@ -168,7 +168,7 @@ END; $$; CREATE OR REPLACE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO pg_catalog, pg_temp AS $$ DECLARE obj record; @@ -205,7 +205,7 @@ END; $$; CREATE OR REPLACE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO pg_catalog, pg_temp AS $_$ DECLARE graphql_is_dropped bool; diff --git a/migrations/schema-15.sql b/migrations/schema-15.sql index 6edaefdb5f..270cf82029 100644 --- a/migrations/schema-15.sql +++ b/migrations/schema-15.sql @@ -169,7 +169,7 @@ $$; CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ BEGIN IF EXISTS ( @@ -215,7 +215,7 @@ COMMENT ON FUNCTION extensions.grant_pg_cron_access() IS 'Grants access to pg_cr CREATE FUNCTION extensions.grant_pg_graphql_access() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $_$ begin if not exists ( @@ -270,7 +270,7 @@ COMMENT ON FUNCTION extensions.grant_pg_graphql_access() IS 'Grants access to pg CREATE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ BEGIN IF EXISTS ( @@ -329,7 +329,7 @@ COMMENT ON FUNCTION extensions.grant_pg_net_access() IS 'Grants access to pg_net CREATE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ DECLARE cmd record; @@ -373,7 +373,7 @@ END; $$; CREATE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ DECLARE obj record; @@ -415,7 +415,7 @@ END; $$; CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $_$ DECLARE graphql_is_dropped bool; diff --git a/migrations/schema-17.sql b/migrations/schema-17.sql index 20b217c5c1..84790a4277 100644 --- a/migrations/schema-17.sql +++ b/migrations/schema-17.sql @@ -170,7 +170,7 @@ $$; CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ BEGIN IF EXISTS ( @@ -216,7 +216,7 @@ COMMENT ON FUNCTION extensions.grant_pg_cron_access() IS 'Grants access to pg_cr CREATE FUNCTION extensions.grant_pg_graphql_access() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $_$ begin if not exists ( @@ -271,7 +271,7 @@ COMMENT ON FUNCTION extensions.grant_pg_graphql_access() IS 'Grants access to pg CREATE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ BEGIN IF EXISTS ( @@ -330,7 +330,7 @@ COMMENT ON FUNCTION extensions.grant_pg_net_access() IS 'Grants access to pg_net CREATE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ DECLARE cmd record; @@ -374,7 +374,7 @@ END; $$; CREATE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ DECLARE obj record; @@ -416,7 +416,7 @@ END; $$; CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $_$ DECLARE graphql_is_dropped bool; diff --git a/migrations/schema-orioledb-17.sql b/migrations/schema-orioledb-17.sql index dcd07d98ca..ff82283437 100644 --- a/migrations/schema-orioledb-17.sql +++ b/migrations/schema-orioledb-17.sql @@ -184,7 +184,7 @@ $$; CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ BEGIN IF EXISTS ( @@ -230,7 +230,7 @@ COMMENT ON FUNCTION extensions.grant_pg_cron_access() IS 'Grants access to pg_cr CREATE FUNCTION extensions.grant_pg_graphql_access() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $_$ begin if not exists ( @@ -285,7 +285,7 @@ COMMENT ON FUNCTION extensions.grant_pg_graphql_access() IS 'Grants access to pg CREATE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ BEGIN IF EXISTS ( @@ -344,7 +344,7 @@ COMMENT ON FUNCTION extensions.grant_pg_net_access() IS 'Grants access to pg_net CREATE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ DECLARE cmd record; @@ -388,7 +388,7 @@ END; $$; CREATE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ DECLARE obj record; @@ -430,7 +430,7 @@ END; $$; CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger LANGUAGE plpgsql - SET search_path TO '' + SET search_path TO 'pg_catalog', 'pg_temp' AS $_$ DECLARE graphql_is_dropped bool; diff --git a/nix/tests/expected/event_trigger_search_path.out b/nix/tests/expected/event_trigger_search_path.out index 9595d5a386..2de883d66f 100644 --- a/nix/tests/expected/event_trigger_search_path.out +++ b/nix/tests/expected/event_trigger_search_path.out @@ -3,9 +3,14 @@ -- Event triggers fire as superuser on DDL run by any role, so an event trigger function -- that resolves names through the caller's search_path can be hijacked by objects the -- caller creates. A migration that redefines one of these functions with CREATE OR REPLACE --- and omits `SET search_path TO ''` silently drops the pin (this happened with --- 20260923021047_update_pgrst_watch_triggers.sql). Extension-owned event triggers are --- excluded: their functions ship with the extension and are not ours to change. +-- and omits the SET clause silently drops the pin (this happened with +-- 20260923021047_update_pgrst_watch_triggers.sql). +-- +-- The pin must be exactly `pg_catalog, pg_temp`. An empty search_path is NOT enough: when +-- pg_temp is not listed it is searched first for relation and type names, so a caller's +-- `create temp table pg_extension (...)` would shadow the catalog. Listing pg_temp +-- explicitly moves it last. Extension-owned event triggers are excluded: their functions +-- ship with the extension and are not ours to change. -- Silent on success, raises on regression. do $$ declare @@ -36,7 +41,7 @@ begin string_agg( format('%s -> %s', evtname, fn::regprocedure), ', ' order by evtname - ) filter (where not ('search_path=""' = any(coalesce(proconfig, '{}')))) + ) filter (where not ('search_path=pg_catalog, pg_temp' = any(coalesce(proconfig, '{}')))) into _checked, _offenders from supabase_event_triggers; @@ -46,7 +51,7 @@ begin end if; if _offenders is not null then - raise exception 'event trigger functions without SET search_path TO '''': %', _offenders; + raise exception 'event trigger functions without SET search_path TO pg_catalog, pg_temp: %', _offenders; end if; end $$; diff --git a/nix/tests/sql/event_trigger_search_path.sql b/nix/tests/sql/event_trigger_search_path.sql index 9595d5a386..2de883d66f 100644 --- a/nix/tests/sql/event_trigger_search_path.sql +++ b/nix/tests/sql/event_trigger_search_path.sql @@ -3,9 +3,14 @@ -- Event triggers fire as superuser on DDL run by any role, so an event trigger function -- that resolves names through the caller's search_path can be hijacked by objects the -- caller creates. A migration that redefines one of these functions with CREATE OR REPLACE --- and omits `SET search_path TO ''` silently drops the pin (this happened with --- 20260923021047_update_pgrst_watch_triggers.sql). Extension-owned event triggers are --- excluded: their functions ship with the extension and are not ours to change. +-- and omits the SET clause silently drops the pin (this happened with +-- 20260923021047_update_pgrst_watch_triggers.sql). +-- +-- The pin must be exactly `pg_catalog, pg_temp`. An empty search_path is NOT enough: when +-- pg_temp is not listed it is searched first for relation and type names, so a caller's +-- `create temp table pg_extension (...)` would shadow the catalog. Listing pg_temp +-- explicitly moves it last. Extension-owned event triggers are excluded: their functions +-- ship with the extension and are not ours to change. -- Silent on success, raises on regression. do $$ declare @@ -36,7 +41,7 @@ begin string_agg( format('%s -> %s', evtname, fn::regprocedure), ', ' order by evtname - ) filter (where not ('search_path=""' = any(coalesce(proconfig, '{}')))) + ) filter (where not ('search_path=pg_catalog, pg_temp' = any(coalesce(proconfig, '{}')))) into _checked, _offenders from supabase_event_triggers; @@ -46,7 +51,7 @@ begin end if; if _offenders is not null then - raise exception 'event trigger functions without SET search_path TO '''': %', _offenders; + raise exception 'event trigger functions without SET search_path TO pg_catalog, pg_temp: %', _offenders; end if; end $$;