basquetWi + New ticket
pluto PLUTO-736

pg_net EXECUTE to PUBLIC: measure whether this ref grants net.http_post to every login role

Blocked high pepmmaster-evolutiva-cc ⛔ Measurement complete (comment 16:46Z); blocked on Elazar. Escalated as eq#108, topic pgnet-public-egress, ref_bwi 736. Three asks: (a) confirm PostgREST exposed schemas in the dashboard - it is API config and cannot be read from the DB, and it is the only thing separating 'any role with SQL' from 'unauthenticated internet egress'; (b) raise the grant_pg_net_access() version-ceiling defect with Supabase support - his account, not a lane's; or (c) rule accept-the-condition and this row closes. No migration is possible from this lane: every net.* function and both net tables are owned by supabase_admin and postgres is not rolsuper here, so a REVOKE warns and ships green while changing nothing.

Per-ref leg of ENAMEL-35 (db-enamel-cc, 2026-09-11). MEASURED on ref eprpywqwzsvrjeaforei: proacl is NULL on every net.* function, which means EXECUTE to PUBLIC, and has_function_privilege on net.http_post returns TRUE for all THIRTEEN login roles including supabase_read_only_user, applog_writer and venusApp. Consequence: any DB credential on that ref is an arbitrary outbound-HTTP primitive with the database as origin, plus net.worker_restart. Table grants do not bound it and no per-relation privilege audit surfaces it. Root cause is upstream: extensions.grant_pg_net_access() contains the correct REVOKE ... FROM PUBLIC but gates it on extversion IN ('0.2'...'0.11.0') under the comment '0.12.0 onwards dont need these applied'; that ref runs 0.20.3 so it never fires and the premise is false. SCOPE FOR THIS REF, read-only: (1) select extversion from pg_extension where extname='pg_net'; (2) has_function_privilege per login role on net.http_post, net.http_get and net.worker_restart; (3) proacl on every net.* function. VERSION IS THE DISCRIMINATOR - do not inherit enamels result, measure this ref. DO NOT WRITE A REVOKE MIGRATION: as postgres the REVOKE raises no error and changes nothing, because a non-owner revoke warns rather than failing, so the migration would ship green and fix nothing; set role supabase_admin is 42501. Remediation is a Supabase support request or an explicit accept-the-condition, and that routes to Elazar.

Sub-tickets

No sub-tickets.
+ Add sub-ticket

Activity

  • wi-cli-venus created · 5w ago
  • wi-cli-venus commented · 5w ago
    MEASURED 2026-09-11 16:55 ART on this ref, read-only, not inherited from ENAMEL-35. All three scope items answered, plus a fourth surface the row's scope did not name and which is materially worse than the one it did. (1) VERSION. pg_extension.extversion for pg_net = 0.20.0 on this ref. The row description says the enamel ref runs 0.20.3 - different patch, same side of the gate, so the conclusion transfers but the number does not. Do not quote 0.20.3 for pluto. (2) FUNCTION ACLs. Twelve functions in schema net, ALL owned by supabase_admin, ALL with proacl NULL: _await_response/1, _encode_url_with_params_array/2, _http_collect_response/2, _urlencode_string/1, check_worker_is_up/0, http_collect_response/2, http_delete/5, http_get/4, http_post/5, wait_until_running/0, wake/0, worker_restart/0. proacl NULL is the default ACL, which for a function is EXECUTE to PUBLIC. (3) PER-ROLE. All 15 login roles return true for EXECUTE on net.http_post, net.http_get, net.http_delete and net.worker_restart: agent_audit_pluto_ca, agent_db_pluto_cc, applog_reader, applog_writer, authenticator, pgbouncer, pm_triage_reader, postgres, supabase_admin, supabase_auth_admin, supabase_etl_admin, supabase_functions_admin, supabase_read_only_user, supabase_replication_admin, supabase_storage_admin. Only supabase_admin is rolsuper. The row says THIRTEEN for enamel; pluto has 15 - count it per ref. ROOT CAUSE CONFIRMED ON THIS REF BY READING THE LIVE prosrc, not inherited. extensions.grant_pg_net_access() gates its whole hardening block on extversion IN ('0.2','0.6','0.7','0.7.1','0.8.0','0.10.0','0.11.0') under the comment "version 0.12.0 onwards don't need these applied". 0.20.0 is outside that list, so the block never fires. Worth noting what ELSE that block would have done besides the REVOKE: it also makes http_get and http_post SECURITY DEFINER and pins SET search_path = net on both. Neither happened here, which independently corroborates the PLUTO-666/migration-119 transitive-callee finding that net.http_get has proconfig NULL - that is not a coincidence, it is the same skipped block. (4) NOT IN SCOPE AND WORSE THAN WHAT WAS. The row scopes to functions. The schema and its TABLES are also open to PUBLIC, which widens the blast radius past login roles entirely: - pg_namespace.nspacl for net contains '=U/supabase_admin' - USAGE on schema net granted to PUBLIC, on top of the explicit grants to supabase_functions_admin/postgres/anon/authenticated/service_role. - net.http_request_queue and net._http_response both carry relacl '{supabase_admin=arwdDxtm/supabase_admin,=arwdDxtm/supabase_admin}'. The empty-grantee entry is PUBLIC, and arwdDxtm is SELECT/INSERT/UPDATE/DELETE/TRUNCATE/REFERENCES/TRIGGER. net.http_request_queue_id_seq is '=rwU' to PUBLIC. - Consequence measured directly: anon, authenticated, service_role and pg_read_all_data - none of which can log in, so none appear in a login-role census - all return true for USAGE on net, EXECUTE on net.http_post, and SELECT *and TRUNCATE* on net._http_response. - So the correct statement of the exposure is PUBLIC, not 'every login role'. Any role that can execute SQL in this database can send arbitrary outbound HTTP with the database as origin, can read every HTTP RESPONSE BODY pg_net has retained, and can TRUNCATE that history. - Retention measured: net.http_request_queue is empty right now (pg_net deletes rows after processing) and net._http_response holds 19 rows spanning 2026-09-11 10:00:23Z to 15:34:05Z, i.e. roughly a 6-hour rolling window of response bodies readable by PUBLIC. The queue being transient is not protection - a role with SELECT on it can poll and catch an Authorization header in flight, and the drain-email-outbox path builds exactly such a header from vault.decrypted_secrets via public.cron_auth_header(). Zero queue rows currently carry an Authorization header; that is a point-in-time read of a table that empties itself, not an absence. - TAMPER-EVIDENCE, not just confidentiality: PUBLIC holding DELETE and TRUNCATE on both tables means any role can silently drop queued requests (availability) or wipe response history (evidence), and nothing in appEvents would record it. REACHABILITY BOUNDARY, stated rather than assumed. anon is the role PostgREST uses for unauthenticated requests, so 'anon can execute net.http_post' is only a REST-reachable hole if schema net is in PostgREST's exposed-schema list. It is not by default (public, graphql_public), and I could NOT verify the live list from the database - there is no pgrst.* GUC and no pg_db_role_setting entry carrying one on this ref; it is API config, not DB state. So do not upgrade this to 'unauthenticated internet egress' without checking the dashboard. What IS reachable today without that: any SECURITY DEFINER function, any SQL-injection site, or any code path running as anon/authenticated/service_role gains outbound HTTP, because the grant is on the role rather than on a route. NO MIGRATION, per the standing ruling and now confirmed structurally on this ref: every net.* function and both net tables are owned by supabase_admin, and this lane connects as postgres, which is NOT rolsuper here. A non-owner REVOKE warns and changes nothing, so the migration would ship green and fix nothing. REMAINING: item (4) is a new surface, not covered by the ENAMEL-35 framing this row inherited, and its remediation is upstream the same way (4) cannot be fixed in a pluto migration. Escalating to Elazar via eq_add with the nspacl/relacl evidence rather than closing on the function-only scope.
  • wi-cli-venus blocked · 5w ago
    Measurement complete (comment 16:46Z); blocked on Elazar. Escalated as eq#108, topic pgnet-public-egress, ref_bwi 736. Three asks: (a) confirm PostgREST exposed schemas in the dashboard - it is API config and cannot be read from the DB, and it is the only thing separating 'any role with SQL' from 'unauthenticated internet egress'; (b) raise the grant_pg_net_access() version-ceiling defect with Supabase support - his account, not a lane's; or (c) rule accept-the-condition and this row closes. No migration is possible from this lane: every net.* function and both net tables are owned by supabase_admin and postgres is not rolsuper here, so a REVOKE warns and ships green while changing nothing.
  • wi-cli-venus commented · 5w ago
    THIRD SURFACE, measured 2026-09-11 17:20 ART on pmmaster's request (db-mars-cc found it on mars first). Presence predicates only - no header value was read, printed, stored or quoted. pg_net does not send inline, it ENQUEUES, headers included, into net.http_request_queue - the table PUBLIC can SELECT per the 16:46 comment. So any auth header a cron job passes through pg_net transits PUBLIC-readable storage on every fire. PLUTO ANSWERS THE FLEET CHECK TRUE / TRUE / FALSE, THE SAME AS MARS - BUT THE MARS PREDICATE RETURNS FALSE HERE, AND THAT GAP IS THE FINDING. Run exactly as specified over all five cron.job rows: calls_net_http FALSE, has_headers_clause FALSE, literal_bearer FALSE, on every job. A ref audited with the command-text predicate is certified clean and is not. Job 10, pluto-drain-email-outbox, schedule */5 * * * *, command length 45: the entire command is a call to public.fn_drain_email_outbox_if_due(). The net.http_get and its headers clause live in THAT FUNCTION'S BODY. Resolving one level down - fn_calls_net_http TRUE, fn_has_headers_clause TRUE, fn_headers_from_secret_fn TRUE (public.cron_auth_header), fn_literal_bearer FALSE. public.cron_auth_header: prosecdef TRUE, reads vault.decrypted_secrets TRUE, proacl {postgres=X/postgres,service_role=X/postgres} - NO PUBLIC entry. So pluto's revoke HOLDS, exactly as MARS-380's does, and the bearer still lands in the PUBLIC-readable queue table every five minutes. That is the sharp part: the control is intact and the secret leaks anyway, by a path that no function-EXECUTE remediation and no two-table remediation touches. TIMING, NOT STANDING DISCLOSURE - the worker deletes the row on drain, so the window is flight time. At */5 that is 288 fires a day and any role polling during one captures it. The 16:46 comment's observation that the queue read zero rows with an Authorization header is a point-in-time read of a self-emptying table, which is exactly why it is not evidence of absence. METHOD CORRECTION OWED TO THE FLEET, sent to pmmaster: the predicate must be TRANSITIVE - match cron.job.command to pg_proc.proname, then run the three predicates against the callee's prosrc. A command-text-only scan false-negatives on any ref that wraps its HTTP call in a function, and wrapping it is the tidier pattern, so the best-factored refs are the ones most likely to pass wrongly. Same failure shape as reporting only the empty-grantee =U out of an nspacl that also carries named grants. Pluto's other four cron jobs are genuinely negative, checked transitively: appEvents-retention-prune, notifications-read-prune, cron-job-run-details-prune, pluto-prune-dbperf-snapshots are local DML with no net.* reachable from any of them. eq#108 body updated to carry all three surfaces. Row stays blocked on Elazar.
  • wi-cli-venus commented · 5w ago
    WIDE-INSTRUMENT ROUND (pmmaster work order, tags ...mtx7c7bxcyqr / ...mtx7hk4psnx8). Two corrections to my own 16:46Z comment on this row first. (1) 'TIMING, NOT STANDING DISCLOSURE' is half wrong. Two tables, two exposure classes. REQUEST side is flight time: net.http_request_queue = 0 rows, worker deletes on drain. RESPONSE side is standing by CONFIGURATION: pg_net.ttl = 6 hours, pg_settings source=default (only pg_net.username comes from /etc/postgresql-custom/conf.d/pg_net.conf). net._http_response = 19 rows, 2026-09-11 10:00:23.856361+00 -> 15:34:05.148469+00, span 5h33m41s; all 19 carry non-NULL headers and non-NULL content, 0 timed_out, 1 distinct status_code. Six hours of retained request headers and response bodies behind a PUBLIC relacl. Stated as grant-plus-retention, NOT as disclosure: nothing records who has SELECTed it, so exposure is measured and exercise is unmeasured. (2) '288 fires a day' was the SCHEDULE rate, not the EGRESS rate. cron.job_run_details: jobid 10 pluto-drain-email-outbox */5 ran its full 72 times in the same 6h window, and 0 of the 19 retained responses fall on a 5-minute boundary. fn_drain_email_outbox_if_due has a due-guard that short-circuits before net.http_get. Never infer an egress rate from a cron schedule when the callee guards. FOURTH SURFACE. My cron-scoped instrument was structurally blind to a trigger caller at every depth. Wide scan - every pg_proc row whose prosrc matches net\.http_, reachability ignored - returns 8: extensions.grant_pg_net_access (version-gated dead block, proconfig search_path=""), six net.* internals (proconfig NULL on all), and two project functions: public.fn_drain_email_outbox_if_due oid 48850 plpgsql SECURITY INVOKER proconfig [search_path=public, pg_temp]; headers TRUE, headers from cron_auth_header() TRUE, literal bearer FALSE, http_get TRUE, due-guard TRUE. Invoked by: CRON (jobid 10). public."fn_emailOutboxWakeTrigger" oid 48861 plpgsql SECURITY INVOKER proconfig [search_path=pg_catalog, public]; headers TRUE, headers from cron_auth_header() TRUE, literal bearer FALSE, http_get TRUE, Authorization key TRUE, EXCEPTION block TRUE, guard TRUE. Invoked by: TRIGGER - trg_emailOutboxWake AFTER INSERT ON public."emailOutbox" REFERENCING NEW TABLE AS new_rows FOR EACH STATEMENT, tgenabled='O'. Pluto reproduces venus's and mars's third caller exactly. Union with the cron command-text scan adds nothing on pluto: all 5 cron jobs test FALSE for net\.http_ in command text (mars is the opposite - 5 direct callers in command text and no function body - so keep both scans, the union is per-ref). ATTRIBUTION, WHICH VENUS AND MARS BOTH BOUNDED AS UNMEASURABLE AND ON PLUTO IS NOT. _http_response has no url column and the matching queue rows are gone, so attributing a retained row to a job is inference unless the ref has a second clock. Pluto has one: all 19 responses fall within +/-2s of an emailOutbox insert, 0 on a 5-minute boundary, last insert 15:34:05.130732+00 vs last response 15:34:05.148469+00. At ~2 inserts/hour over a 4s window that is ~0.2%/row by chance, so 19/19 is not coincidence. THE ENTIRE RETAINED CORPUS IS TRIGGER-PATH; THE CRON CONTRIBUTED NOTHING DESPITE 72 FIRES. Caveat: createdAt is transaction-start now(), so the 18ms gap is a clock artifact, not a round-trip. REACH OF THE TRIGGER PATH IS NARROWER THAN THE FUNCTION-ACL PICTURE. Trigger-function EXECUTE is not checked at fire time, so what matters is INSERT on public."emailOutbox": TRUE only for agent_db_pluto_cc, postgres, service_role. FALSE for anon, authenticated, authenticator, pg_read_all_data, applog_reader, applog_writer, pm_triage_reader, agent_audit_pluto_ca, supabase_read_only_user. ACLs hold on all three project fns (cron_auth_header, fn_drain_email_outbox_if_due, fn_emailOutboxWakeTrigger): owner postgres, proacl {postgres=X/postgres,service_role=X/postgres}, no PUBLIC entry. Egress cadence is the outbox enqueue rate: 44 statements/24h, 298/7d, 3096 total, driven from src/lib/email-outbox.ts:56 and :82. FOURTH WIDENING ON PLUTO, EXPLICIT NEGATIVES: rules 0, policies 0, check constraints 0, view/matview defs via pg_get_viewdef 0, dynamic-SQL fns in public 0 (6 realtime, 2 storage, 1 extensions, none referencing net), unqualified \mhttp_(get|post|delete)\M outside schema net 0. pg_attrdef returned 1 and it is a FALSE POSITIVE - net.http_request_queue.id defaults to nextval('net.http_request_queue_id_seq'::regclass) and the regex matched the SEQUENCE name. Event triggers: 6 exist, NOT predicate-matched - open gap, not a negative. WHAT THE INSTRUMENT STILL CANNOT SEE. The structural one invalidates the approach, not just its coverage: a pg_proc caller-set enumeration cannot bound who can cause egress, because nothing has to call a function at all - net.http_request_queue carries relacl {supabase_admin=arwdDxtm/supabase_admin,=arwdDxtm/supabase_admin}, so PUBLIC holds INSERT and a row can be enqueued directly with no pg_proc row anywhere to scan. Revoking EXECUTE on every net.http_* would not close it. Secondary: nothing logs who SELECTs _http_response, so the read side has no instrument at all; callers outside the database (edge functions, any external holder of a DB credential - pluto app roles hold EXECUTE) are invisible to any catalog scan; a prosrc regex cannot see dynamically-assembled names (measured 0 in public here, which is a per-ref measurement, not a property of the instrument). Measured with presence predicates and counts only. No header or response value was read, printed or stored. No dispatch was tested.
  • wi-cli-venus commented · 5w ago
    FIFTH WIDENING - EVENT TRIGGERS. Measured 2026-09-11, the one open task from pmmaster's THREAD CLOSED decision (tag ...mtx7rkxli0cv). Reported here rather than on the wire, per that decision's routing. COUNT: 6 event triggers on this ref, all enabled (evtenabled='O'), all owned by supabase_admin, all Supabase platform objects - zero project-authored: issue_graphql_placeholder sql_drop -> extensions.set_graphql_placeholder srclen 1573 issue_pg_cron_access ddl_command_end -> extensions.grant_pg_cron_access srclen 1194 issue_pg_graphql_access ddl_command_end -> extensions.grant_pg_graphql_access srclen 1357 issue_pg_net_access ddl_command_end -> extensions.grant_pg_net_access srclen 1999 pgrst_ddl_watch ddl_command_end -> extensions.pgrst_ddl_watch srclen 729 pgrst_drop_watch sql_drop -> extensions.pgrst_drop_watch srclen 412 NEGATIVE, STATED EXPLICITLY: 5 of 6 match nothing - prosrc ~* 'net\.http_' FALSE, unqualified \mhttp_(get|post|delete)\M FALSE, and no reference to fn_drain_email_outbox_if_due, fn_emailOutboxWakeTrigger, cron_auth_header or http_request_queue. No event trigger on this ref causes pg_net egress. THE ONE MATCH IS A REFERENCE, NOT A CALL, AND THE DISTINCTION IS THE WHOLE POINT. issue_pg_net_access -> extensions.grant_pg_net_access matches net\.http_ eight times, and every one is DDL ABOUT those functions rather than an invocation of them: invokes_net_http (PERFORM/SELECT/:=/= followed by net.http_) FALSE, only_ddl_references (ALTER/GRANT/REVOKE ... net.http_) TRUE, version_gated TRUE. It is the same version-gated dead block already reported in the wide pg_proc scan as row 1 of 8 - the function whose skipped REVOKE is the root cause of this entire row. So the fifth widening returns no NEW caller on pluto; it returns the root cause a second time, by a second instrument. WORTH CARRYING: that function is bound to a ddl_command_end event trigger, so it re-executes on EVERY DDL statement in this database - including every migration this lane ships. It has run hundreds of times and hardened nothing, because extversion 0.20.0 falls outside its IN-list every time. A remediation that raises the version ceiling would therefore self-apply on the next DDL with no migration needed; a remediation written as a one-shot migration would not survive an extension upgrade that re-runs the gated logic. Both are upstream, neither is writable from this lane (owner supabase_admin, postgres is not rolsuper here). METHOD NOTE for the fleet instrument: a prosrc regex cannot distinguish 'calls net.http_get' from 'ALTERs net.http_get'. Any ref running the fifth widening will get this same match and must classify it before reporting a caller - the predicate pair (PERFORM|SELECT|:=|=)\s*net\.http_ versus (ALTER|GRANT|REVOKE)[^;]*net\.http_ separates them in one query. Counts and booleans only. No prosrc body, header or response value was printed or stored.
  • wi-cli-venus assigned · 4w ago
    unassigned
  • wi-cli-venus note · 4w ago
    Assignee cleared by pmmaster-evolutiva-cc 2026-09-12: coder-venus-cc / db-venus-cc / coder-pluto-cc / db-pluto-cc are absent from the hub roster and have no pane, and venus and pluto have no staffed coder or db lane. STATUS DELIBERATELY LEFT AS blocked - the blocker is a fact about the work and does not depend on who was holding the row, so it survives the unassignment. The blockedReason has NOT been re-verified in this pass; treat its age as unknown until someone reads the rows own events. Restaffing is Elazars call.
  • pmmaster-evolutiva-cc assigned · 6d ago
    pmmaster-evolutiva-cc
security
5w ago by wi-cli-venus
6d ago