From 99c6f83df0f67a4ad91c73b433bb6a18f1c229a4 Mon Sep 17 00:00:00 2001 From: Dennis Felsing Date: Fri, 3 Jul 2026 08:27:09 +0000 Subject: [PATCH 1/2] sql: resolve pg_get_viewdef view names via search path pg_get_viewdef's text overloads matched the bare view name against mz_views across every schema of every database. That threw "more than one record produced in subquery" whenever two views shared a name anywhere in the environment, resolved views in other databases, and rejected schema-qualified names. Route the text overloads through the search-path-aware text -> regclass cast instead, matching PostgreSQL's regclass semantics (current database, search path, first match). Not-found now errors like PostgreSQL rather than returning NULL. Part of SQL-450. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/sql/src/func.rs | 11 ++++-- test/sqllogictest/pg_get_viewdef.slt | 50 +++++++++++++++++++++++----- test/sqllogictest/rbac_mcp_agent.slt | 2 +- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/sql/src/func.rs b/src/sql/src/func.rs index b7f80797264f8..489468fd3521a 100644 --- a/src/sql/src/func.rs +++ b/src/sql/src/func.rs @@ -2697,14 +2697,21 @@ pub static PG_CATALOG_BUILTINS: LazyLock> = LazyLoc // pg_get_viewdef returns the (query part of) the given view's definition. // We currently don't support pretty-printing (the `Bool`/`Int32` parameters). "pg_get_viewdef" => Scalar { + // The text overloads resolve the name through the search-path-aware + // text -> regclass cast, matching PostgreSQL's regclass semantics + // (current database, search path, first match). Matching on the bare + // `name` instead would resolve across every schema and database, so + // any same-named view anywhere would make the lookup ambiguous. params!(String) => sql_impl_func( - "(SELECT definition FROM mz_catalog.mz_views WHERE name = $1)" + "(SELECT definition FROM mz_catalog.mz_views + WHERE oid = CAST(CAST($1 AS pg_catalog.regclass) AS pg_catalog.oid))" ) => String, 1640; params!(Oid) => sql_impl_func( "(SELECT definition FROM mz_catalog.mz_views WHERE oid = $1)" ) => String, 1641; params!(String, Bool) => sql_impl_func( - "(SELECT definition FROM mz_catalog.mz_views WHERE name = $1)" + "(SELECT definition FROM mz_catalog.mz_views + WHERE oid = CAST(CAST($1 AS pg_catalog.regclass) AS pg_catalog.oid))" ) => String, 2505; params!(Oid, Bool) => sql_impl_func( "(SELECT definition FROM mz_catalog.mz_views WHERE oid = $1)" diff --git a/test/sqllogictest/pg_get_viewdef.slt b/test/sqllogictest/pg_get_viewdef.slt index 37cbb1e4936b9..39baaa43a1cfc 100644 --- a/test/sqllogictest/pg_get_viewdef.slt +++ b/test/sqllogictest/pg_get_viewdef.slt @@ -25,16 +25,22 @@ CREATE VIEW t_view AS SELECT t.a, b FROM t # Test pg_get_viewdef(view_name) -query T +# The text overload resolves through regclass, so an unknown name errors +# (matching PostgreSQL) rather than returning NULL. +query error relation "doesnotexist" does not exist SELECT pg_get_viewdef('doesnotexist') ----- -NULL query T SELECT pg_get_viewdef('t_view') ---- SELECT "t"."a", "b" FROM [u1 AS "materialize"."public"."t"]; +# Schema-qualified names resolve (previously returned NULL). +query T +SELECT pg_get_viewdef('public.t_view') +---- +SELECT "t"."a", "b" FROM [u1 AS "materialize"."public"."t"]; + # Test pg_get_viewdef(view_oid) query T @@ -49,15 +55,11 @@ SELECT "t"."a", "b" FROM [u1 AS "materialize"."public"."t"]; # Test pg_get_viewdef(view_name, pretty) -query T +query error relation "doesnotexist" does not exist SELECT pg_get_viewdef('doesnotexist', true) ----- -NULL -query T +query error relation "doesnotexist" does not exist SELECT pg_get_viewdef('doesnotexist', false) ----- -NULL query T SELECT pg_get_viewdef('t_view', true) @@ -112,3 +114,33 @@ query T SELECT pg_get_viewdef('t_view'::regclass::oid) ---- SELECT "t2"."a", "b" FROM [u1 AS "materialize"."public"."t2"]; + +# A same-named view in another schema must not make the text overload ambiguous. +# The name resolves via the search path, not by matching the bare name globally. + +statement ok +CREATE SCHEMA other + +statement ok +CREATE VIEW other.t_view AS SELECT c FROM t2 + +query T +SELECT pg_get_viewdef('t_view') +---- +SELECT "t2"."a", "b" FROM [u1 AS "materialize"."public"."t2"]; + +query T +SELECT pg_get_viewdef('other.t_view') +---- +SELECT "c" FROM [u1 AS "materialize"."public"."t2"]; + +statement ok +SET search_path = other + +query T +SELECT pg_get_viewdef('t_view') +---- +SELECT "c" FROM [u1 AS "materialize"."public"."t2"]; + +statement ok +RESET search_path diff --git a/test/sqllogictest/rbac_mcp_agent.slt b/test/sqllogictest/rbac_mcp_agent.slt index ceedf9fafc63a..b842aac794eb3 100644 --- a/test/sqllogictest/rbac_mcp_agent.slt +++ b/test/sqllogictest/rbac_mcp_agent.slt @@ -765,7 +765,7 @@ DETAIL: Access to system catalog objects is restricted for this role. Contact yo simple conn=agent_restricted,user=agent SELECT pg_get_viewdef('next_arrivals'); ---- -db error: ERROR: access to system object mz_views is restricted +db error: ERROR: access to system object mz_databases is restricted DETAIL: Access to system catalog objects is restricted for this role. Contact your administrator if you need access. # obj_description queries system tables; pg_class is the first blocked ID encountered From b1c12502e0af0d4a08d67f8f6e8ee08a9464423f Mon Sep 17 00:00:00 2001 From: Dennis Felsing Date: Fri, 3 Jul 2026 08:27:09 +0000 Subject: [PATCH 2/2] postgres-util: pin search_path to pg_catalog on upstream connections Defense against upstream search_path hijacking. An upstream role default such as `ALTER ROLE mz_user SET search_path = attacker, pg_catalog` could otherwise shadow the unqualified pg_catalog relations and functions our source metadata queries rely on, letting a hostile upstream feed Materialize forged schema, publication, or RLS-policy rows. Setting search_path through the connection startup options carries GUC source PGC_S_CLIENT, which outranks the PGC_S_USER / PGC_S_DATABASE defaults set by ALTER ROLE / ALTER DATABASE. Applying it at the single connection chokepoint covers every upstream connection, normal and replication alike, and every current and future query, without qualifying each reference by hand. Part of SQL-450. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/postgres-util/src/tunnel.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/postgres-util/src/tunnel.rs b/src/postgres-util/src/tunnel.rs index e4d2110d40d79..89b827e3275cb 100644 --- a/src/postgres-util/src/tunnel.rs +++ b/src/postgres-util/src/tunnel.rs @@ -191,6 +191,20 @@ impl Config { let mut postgres_config = self.inner.clone(); configure(&mut postgres_config); + // Pin the search path to `pg_catalog` for every upstream connection. Our catalog queries + // reference `pg_catalog` relations and functions by unqualified name (and user tables by a + // fully-qualified name), so an upstream role default such as + // `ALTER ROLE mz_user SET search_path = attacker, pg_catalog` must not be able to shadow + // them with attacker-owned relations or functions. The `options` startup parameter carries + // GUC source `PGC_S_CLIENT`, which outranks the `PGC_S_USER`/`PGC_S_DATABASE` defaults set + // by `ALTER ROLE`/`ALTER DATABASE`, so this holds even against a hostile per-role default. + let search_path_option = "-c search_path=pg_catalog"; + let options = match postgres_config.get_options() { + Some(existing) => format!("{existing} {search_path_option}"), + None => search_path_option.to_string(), + }; + postgres_config.options(options); + let mut tls = mz_tls_util::make_tls(&postgres_config).map_err(|tls_err| match tls_err { mz_tls_util::TlsError::Generic(e) => PostgresError::Generic(e), mz_tls_util::TlsError::OpenSsl(e) => PostgresError::PostgresSsl(e),