diff --git a/migrations/db/migrations/20261016000001_search_path_for_supa_functions.sql b/migrations/db/migrations/20261016000001_search_path_for_supa_functions.sql new file mode 100644 index 0000000000..286eb69867 --- /dev/null +++ b/migrations/db/migrations/20261016000001_search_path_for_supa_functions.sql @@ -0,0 +1,258 @@ +-- migrate:up + +create or replace function extensions.grant_pg_graphql_access() + returns event_trigger + language plpgsql + set search_path to pg_catalog, pg_temp +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 + 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$; + + +CREATE OR REPLACE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger + LANGUAGE plpgsql + SET search_path to pg_catalog, pg_temp + 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 cascade; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger + LANGUAGE plpgsql + SET search_path to pg_catalog, pg_temp + 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 pg_catalog, pg_temp + 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' + , '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 + -- 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 pg_catalog, pg_temp + 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' + , 'foreign table' + , 'view' + , 'materialized view' + , 'function' + , 'trigger' + , 'type' + , '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; + END LOOP; +END; $$; + +CREATE OR REPLACE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger + LANGUAGE plpgsql + SET search_path TO pg_catalog, pg_temp + 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 + 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 diff --git a/migrations/schema-15.sql b/migrations/schema-15.sql index 5a27f2acac..270cf82029 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 'pg_catalog', 'pg_temp' 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 'pg_catalog', 'pg_temp' 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 @@ -268,6 +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 'pg_catalog', 'pg_temp' AS $$ BEGIN IF EXISTS ( @@ -294,7 +297,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 +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 'pg_catalog', 'pg_temp' AS $$ DECLARE cmd record; @@ -369,6 +373,7 @@ END; $$; CREATE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ DECLARE obj record; @@ -410,6 +415,7 @@ END; $$; CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger LANGUAGE plpgsql + 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 7453a16e21..84790a4277 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 'pg_catalog', 'pg_temp' 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 'pg_catalog', 'pg_temp' 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 @@ -269,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 'pg_catalog', 'pg_temp' AS $$ BEGIN IF EXISTS ( @@ -295,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; @@ -327,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 'pg_catalog', 'pg_temp' AS $$ DECLARE cmd record; @@ -370,6 +374,7 @@ END; $$; CREATE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ DECLARE obj record; @@ -411,6 +416,7 @@ END; $$; CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger LANGUAGE plpgsql + 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 5cdd7b1fec..ff82283437 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 'pg_catalog', 'pg_temp' 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 'pg_catalog', 'pg_temp' 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 @@ -283,6 +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 'pg_catalog', 'pg_temp' AS $$ BEGIN IF EXISTS ( @@ -309,7 +312,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 +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 'pg_catalog', 'pg_temp' AS $$ DECLARE cmd record; @@ -384,6 +388,7 @@ END; $$; CREATE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger LANGUAGE plpgsql + SET search_path TO 'pg_catalog', 'pg_temp' AS $$ DECLARE obj record; @@ -425,6 +430,7 @@ END; $$; CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger LANGUAGE plpgsql + 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 new file mode 100644 index 0000000000..2de883d66f --- /dev/null +++ b/nix/tests/expected/event_trigger_search_path.out @@ -0,0 +1,57 @@ +-- 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 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 + _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=pg_catalog, pg_temp' = 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 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 new file mode 100644 index 0000000000..2de883d66f --- /dev/null +++ b/nix/tests/sql/event_trigger_search_path.sql @@ -0,0 +1,57 @@ +-- 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 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 + _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=pg_catalog, pg_temp' = 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 pg_catalog, pg_temp: %', _offenders; + end if; +end +$$;