From 6558016d62c5e09592aa835541efd43adba587e3 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Mon, 6 Jul 2026 21:40:11 +0530 Subject: [PATCH 1/7] fix: make pg_net background worker run as postgres --- ansible/files/postgresql_config/conf.d/pg_net.conf | 1 + ansible/tasks/finalize-ami.yml | 3 ++- docker/pgctld/orioledb-postgresql.conf.tmpl | 1 + docker/pgctld/postgresql.conf.tmpl | 1 + nix/tests/postgresql.conf.in | 1 + 5 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 ansible/files/postgresql_config/conf.d/pg_net.conf 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 a8531dd770..733c1ec85f 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 13a60e8492..f494d0e32e 100644 --- a/docker/pgctld/orioledb-postgresql.conf.tmpl +++ b/docker/pgctld/orioledb-postgresql.conf.tmpl @@ -281,6 +281,7 @@ restart_after_crash = off # reinitialize after backend crash? # 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 51c30f5cc8..ca07c3bc36 100644 --- a/docker/pgctld/postgresql.conf.tmpl +++ b/docker/pgctld/postgresql.conf.tmpl @@ -283,6 +283,7 @@ restart_after_crash = off # reinitialize after backend crash? # 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/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' From ae43f7d2fa89431c22ecfbddf9aedbce6afa3cdc Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 10 Jul 2026 13:40:21 -0400 Subject: [PATCH 2/7] chore: suffix to test --- ansible/vars.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ansible/vars.yml b/ansible/vars.yml index 4db5397b1a..5f6f980d7a 100644 --- a/ansible/vars.yml +++ b/ansible/vars.yml @@ -10,9 +10,9 @@ postgres_major: # Full version strings for each major version postgres_release: - postgresorioledb-17: "17.6.0.100-orioledb" - postgres17: "17.6.1.143" - postgres15: "15.14.1.143" + postgresorioledb-17: "17.6.0.099-orioledb-bgw-1" + postgres17: "17.6.1.142-bgw-1" + postgres15: "15.14.1.142-bgw-1" # Non Postgres Extensions pgbouncer_release: 1.25.1 From d0142841eee9643c5ef21da37257e0da8a908d23 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Wed, 15 Jul 2026 19:10:01 +0530 Subject: [PATCH 3/7] tests: add pg_net privesc test --- nix/checks.nix | 1 + .../expected/pg_net_worker_privileges.out | 75 ++++++++++++++++++ nix/tests/sql/pg_net_worker_privileges.sql | 76 +++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 nix/tests/expected/pg_net_worker_privileges.out create mode 100644 nix/tests/sql/pg_net_worker_privileges.sql 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..0089d9c4ac --- /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 plain `authenticated` 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, an `authenticated` 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 `authenticated` 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 authenticated; +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/sql/pg_net_worker_privileges.sql b/nix/tests/sql/pg_net_worker_privileges.sql new file mode 100644 index 0000000000..c6651dba97 --- /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 plain `authenticated` 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, an `authenticated` 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 `authenticated` 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 authenticated; + +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; From 0248c2ecbe0f224359d240819a0b099a56681c5c Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Wed, 15 Jul 2026 19:24:43 +0530 Subject: [PATCH 4/7] chore: bump versions for tests --- ansible/vars.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ansible/vars.yml b/ansible/vars.yml index 5f6f980d7a..239ec8f65f 100644 --- a/ansible/vars.yml +++ b/ansible/vars.yml @@ -10,9 +10,9 @@ postgres_major: # Full version strings for each major version postgres_release: - postgresorioledb-17: "17.6.0.099-orioledb-bgw-1" - postgres17: "17.6.1.142-bgw-1" - postgres15: "15.14.1.142-bgw-1" + postgresorioledb-17: "17.6.0.103-orioledb-bgw-1" + postgres17: "17.6.1.146-bgw-1" + postgres15: "15.14.1.146-bgw-1" # Non Postgres Extensions pgbouncer_release: 1.25.1 From 3a374f99c0ec93b7aebd30b5c66b50e20d5f020a Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Thu, 16 Jul 2026 09:48:55 +0530 Subject: [PATCH 5/7] chore: update version to generate a test AMI --- ansible/vars.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ansible/vars.yml b/ansible/vars.yml index 2d4b3f46ab..6085736a8d 100644 --- a/ansible/vars.yml +++ b/ansible/vars.yml @@ -10,9 +10,9 @@ postgres_major: # Full version strings for each major version postgres_release: - postgresorioledb-17: "17.6.0.104-orioledb" - postgres17: "17.6.1.147" - postgres15: "15.14.1.147" + postgresorioledb-17: "17.6.0.103-orioledb-bgw-1" + postgres17: "17.6.1.146-bgw-1" + postgres15: "15.14.1.146-bgw-1" # Non Postgres Extensions pgbouncer_release: 1.25.1 From f1c6cd5858fee4ce9ddca43ee42e1f47d4b0ac1a Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Thu, 16 Jul 2026 16:49:29 +0530 Subject: [PATCH 6/7] chore: revert versions --- ansible/vars.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ansible/vars.yml b/ansible/vars.yml index 6085736a8d..4ba1b7c34a 100644 --- a/ansible/vars.yml +++ b/ansible/vars.yml @@ -10,9 +10,9 @@ postgres_major: # Full version strings for each major version postgres_release: - postgresorioledb-17: "17.6.0.103-orioledb-bgw-1" - postgres17: "17.6.1.146-bgw-1" - postgres15: "15.14.1.146-bgw-1" + postgresorioledb-17: "17.9.0.001-orioledb" + postgres17: "17.6.1.148" + postgres15: "15.14.1.148" # Non Postgres Extensions pgbouncer_release: 1.25.1 From c1a641f022683f4cb2043d4b7a410a82f1bdd2a1 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Mon, 20 Jul 2026 16:54:37 +0530 Subject: [PATCH 7/7] fix: use postgres role in test --- nix/tests/expected/pg_net_worker_privileges.out | 8 ++++---- nix/tests/sql/pg_net_worker_privileges.sql | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/nix/tests/expected/pg_net_worker_privileges.out b/nix/tests/expected/pg_net_worker_privileges.out index 0089d9c4ac..51eb6a62cd 100644 --- a/nix/tests/expected/pg_net_worker_privileges.out +++ b/nix/tests/expected/pg_net_worker_privileges.out @@ -10,13 +10,13 @@ -- 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 plain `authenticated` app user -- can attach +-- 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, an `authenticated` user +-- 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 `authenticated` and has it record which role +-- 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 ( @@ -37,7 +37,7 @@ begin end; $$; grant execute on function public.pg_net_privesc_trigger() to public; -set session authorization authenticated; +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(); diff --git a/nix/tests/sql/pg_net_worker_privileges.sql b/nix/tests/sql/pg_net_worker_privileges.sql index c6651dba97..cdd2011417 100644 --- a/nix/tests/sql/pg_net_worker_privileges.sql +++ b/nix/tests/sql/pg_net_worker_privileges.sql @@ -10,13 +10,13 @@ -- 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 plain `authenticated` app user -- can attach +-- 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, an `authenticated` user +-- 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 `authenticated` and has it record which role +-- 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`. @@ -42,7 +42,7 @@ $$; grant execute on function public.pg_net_privesc_trigger() to public; -set session authorization authenticated; +set session authorization postgres; create trigger pg_net_privesc after insert on net._http_response