diff --git a/ansible/files/postgresql_config/conf.d/pg_net.conf b/ansible/files/postgresql_config/conf.d/pg_net.conf new file mode 100644 index 0000000000..d863cad1c8 --- /dev/null +++ b/ansible/files/postgresql_config/conf.d/pg_net.conf @@ -0,0 +1 @@ +pg_net.username = 'postgres' diff --git a/ansible/tasks/finalize-ami.yml b/ansible/tasks/finalize-ami.yml index 0735e69182..a1924a230f 100644 --- a/ansible/tasks/finalize-ami.yml +++ b/ansible/tasks/finalize-ami.yml @@ -4,7 +4,7 @@ group: 'postgres' src: 'files/postgresql_config/postgresql-csvlog.conf' -- name: auto_explain and pg_cron confs +- name: auto_explain, pg_cron, and pg_net confs ansible.builtin.template: dest: "/etc/postgresql-custom/conf.d/{{ ext_item }}.conf" group: 'postgres' @@ -12,6 +12,7 @@ loop: - auto_explain - pg_cron + - pg_net loop_control: loop_var: 'ext_item' diff --git a/docker/pgctld/orioledb-postgresql.conf.tmpl b/docker/pgctld/orioledb-postgresql.conf.tmpl index 7478f8d0d8..caedde706b 100644 --- a/docker/pgctld/orioledb-postgresql.conf.tmpl +++ b/docker/pgctld/orioledb-postgresql.conf.tmpl @@ -286,6 +286,7 @@ include = '/etc/postgresql-custom/supautils.conf' # Add settings for extensions here auto_explain.log_min_duration = 10s cron.database_name = 'postgres' +pg_net.username = 'postgres' pgsodium.getkey_script = '/usr/lib/postgresql/bin/pgsodium_getkey.sh' vault.getkey_script = '/usr/lib/postgresql/bin/pgsodium_getkey.sh' wal_log_hints = 'on' diff --git a/docker/pgctld/postgresql.conf.tmpl b/docker/pgctld/postgresql.conf.tmpl index 4cace57d1c..cb74a6d819 100644 --- a/docker/pgctld/postgresql.conf.tmpl +++ b/docker/pgctld/postgresql.conf.tmpl @@ -288,6 +288,7 @@ include = '/etc/postgresql-custom/supautils.conf' # Add settings for extensions here auto_explain.log_min_duration = 10s cron.database_name = 'postgres' +pg_net.username = 'postgres' pgsodium.getkey_script = '/usr/lib/postgresql/bin/pgsodium_getkey.sh' vault.getkey_script = '/usr/lib/postgresql/bin/pgsodium_getkey.sh' wal_log_hints = 'on' diff --git a/nix/checks.nix b/nix/checks.nix index b328ccb2cf..776491bc80 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -252,6 +252,7 @@ "security" # depends on various extensions "extensions_schema" # tests extension loading "roles" # includes roles/schemas from extensions not in CLI (pgtle, pgmq, repack, topology) + "pg_net_worker_privileges" # needs the authenticated/postgres roles from the full migrations, not present in the CLI prime file # Version-specific extension tests "z_17_ext_interface" "z_17_pg_stat_monitor" diff --git a/nix/tests/expected/pg_net_worker_privileges.out b/nix/tests/expected/pg_net_worker_privileges.out new file mode 100644 index 0000000000..51eb6a62cd --- /dev/null +++ b/nix/tests/expected/pg_net_worker_privileges.out @@ -0,0 +1,75 @@ +-- Regression test for a privilege-escalation vulnerability in pg_net's background +-- worker. See ansible/files/postgresql_config/conf.d/pg_net.conf, which sets +-- pg_net.username = 'postgres' to fix it. +-- +-- pg_net.username controls which role the background worker uses to drain +-- net.http_request_queue and write to net._http_response. When unset, the worker +-- connects as the bootstrap superuser -- supabase_admin on this platform, a true +-- superuser. +-- +-- pg_net's own install script grants ALL privileges (which includes TRIGGER) on +-- both of those tables to PUBLIC: +-- grant all on all tables in schema net to PUBLIC; +-- so literally any role -- including a `postgres` app user -- can attach +-- a trigger to them. Since a normal (non SECURITY DEFINER) trigger runs with the +-- privileges of whoever performs the triggering statement, a `postgres` user +-- could make such a trigger run arbitrary SQL with the pg_net worker's privileges +-- the next time it processed a queued request. +-- +-- This test plants such a trigger as `postgres` and has it record which role +-- and privilege level it actually ran with, confirming the worker's DML now runs +-- as the unprivileged `postgres` role instead of the superuser `supabase_admin`. +create table public.pg_net_privesc_log ( + recorded_role name, + recorded_is_superuser bool +); +grant insert on public.pg_net_privesc_log to public; +create function public.pg_net_privesc_trigger() returns trigger +language plpgsql +security invoker +as $$ +begin + -- records the privileges of whoever actually performed the triggering + -- statement -- i.e. the pg_net worker, not whoever created this trigger + insert into public.pg_net_privesc_log (recorded_role, recorded_is_superuser) + values (current_user, (select rolsuper from pg_roles where rolname = current_user)); + return null; +end; +$$; +grant execute on function public.pg_net_privesc_trigger() to public; +set session authorization postgres; +create trigger pg_net_privesc +after insert on net._http_response +for each row execute function public.pg_net_privesc_trigger(); +select net.http_get( + 'http://localhost:' || (select value from test_config where key = 'http_mock_port') || '/get' +) is not null as request_queued; + request_queued +---------------- + t +(1 row) + +reset session authorization; +-- give the background worker time to drain the queue and process the response +do $$ +declare + i int := 0; +begin + while (select count(*) from public.pg_net_privesc_log) = 0 and i < 40 loop + perform pg_sleep(0.25); + i := i + 1; + end loop; +end; +$$; +-- the pg_net worker must have run the trigger as the unprivileged `postgres` +-- role, not the superuser `supabase_admin` +select recorded_role, recorded_is_superuser from public.pg_net_privesc_log; + recorded_role | recorded_is_superuser +---------------+----------------------- + postgres | f +(1 row) + +-- cleanup +drop trigger if exists pg_net_privesc on net._http_response; +drop function if exists public.pg_net_privesc_trigger(); +drop table public.pg_net_privesc_log; diff --git a/nix/tests/postgresql.conf.in b/nix/tests/postgresql.conf.in index 9ae301ac36..20968d7b1d 100644 --- a/nix/tests/postgresql.conf.in +++ b/nix/tests/postgresql.conf.in @@ -799,3 +799,4 @@ vault.getkey_script = '@PGSODIUM_GETKEY_SCRIPT@' auto_explain.log_min_duration = 10s cron.database_name = 'postgres' +pg_net.username = 'postgres' diff --git a/nix/tests/sql/pg_net_worker_privileges.sql b/nix/tests/sql/pg_net_worker_privileges.sql new file mode 100644 index 0000000000..cdd2011417 --- /dev/null +++ b/nix/tests/sql/pg_net_worker_privileges.sql @@ -0,0 +1,76 @@ +-- Regression test for a privilege-escalation vulnerability in pg_net's background +-- worker. See ansible/files/postgresql_config/conf.d/pg_net.conf, which sets +-- pg_net.username = 'postgres' to fix it. +-- +-- pg_net.username controls which role the background worker uses to drain +-- net.http_request_queue and write to net._http_response. When unset, the worker +-- connects as the bootstrap superuser -- supabase_admin on this platform, a true +-- superuser. +-- +-- pg_net's own install script grants ALL privileges (which includes TRIGGER) on +-- both of those tables to PUBLIC: +-- grant all on all tables in schema net to PUBLIC; +-- so literally any role -- including a `postgres` app user -- can attach +-- a trigger to them. Since a normal (non SECURITY DEFINER) trigger runs with the +-- privileges of whoever performs the triggering statement, a `postgres` user +-- could make such a trigger run arbitrary SQL with the pg_net worker's privileges +-- the next time it processed a queued request. +-- +-- This test plants such a trigger as `postgres` and has it record which role +-- and privilege level it actually ran with, confirming the worker's DML now runs +-- as the unprivileged `postgres` role instead of the superuser `supabase_admin`. + +create table public.pg_net_privesc_log ( + recorded_role name, + recorded_is_superuser bool +); + +grant insert on public.pg_net_privesc_log to public; + +create function public.pg_net_privesc_trigger() returns trigger +language plpgsql +security invoker +as $$ +begin + -- records the privileges of whoever actually performed the triggering + -- statement -- i.e. the pg_net worker, not whoever created this trigger + insert into public.pg_net_privesc_log (recorded_role, recorded_is_superuser) + values (current_user, (select rolsuper from pg_roles where rolname = current_user)); + return null; +end; +$$; + +grant execute on function public.pg_net_privesc_trigger() to public; + +set session authorization postgres; + +create trigger pg_net_privesc +after insert on net._http_response +for each row execute function public.pg_net_privesc_trigger(); + +select net.http_get( + 'http://localhost:' || (select value from test_config where key = 'http_mock_port') || '/get' +) is not null as request_queued; + +reset session authorization; + +-- give the background worker time to drain the queue and process the response +do $$ +declare + i int := 0; +begin + while (select count(*) from public.pg_net_privesc_log) = 0 and i < 40 loop + perform pg_sleep(0.25); + i := i + 1; + end loop; +end; +$$; + +-- the pg_net worker must have run the trigger as the unprivileged `postgres` +-- role, not the superuser `supabase_admin` +select recorded_role, recorded_is_superuser from public.pg_net_privesc_log; + +-- cleanup +drop trigger if exists pg_net_privesc on net._http_response; +drop function if exists public.pg_net_privesc_trigger(); +drop table public.pg_net_privesc_log;