From 981d96effd992f717ff69bb5a42f42552ff21eed Mon Sep 17 00:00:00 2001 From: Utkarash Singh Date: Tue, 16 Jun 2026 16:24:39 +0530 Subject: [PATCH] fix: default API-exposure lints to public schema when pgrst.db_schemas is unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API-exposure lints filter on the schemas listed in the `pgrst.db_schemas` setting. When that GUC is unset — e.g. running splinter.sql directly via psql — current_setting('pgrst.db_schemas','t') returns NULL, the schema array resolves to empty, and the lints silently return 0 rows instead of surfacing real exposures. Wrap the setting in coalesce(..., 'public') so an unset GUC defaults to the `public` schema (PostgREST's default). An explicitly empty value ('') still means "expose nothing" and is left unchanged, preserving the existing 0017_foreign_table_in_api contract. Adds a regression test (0013) covering the unset-GUC path and documents the behaviour in the README. PSQL-1309 --- README.md | 12 ++++++++++++ lints/0002_auth_users_exposed.sql | 2 +- lints/0010_security_definer_view.sql | 2 +- lints/0013_rls_disabled_in_public.sql | 2 +- lints/0016_materialized_view_in_api.sql | 2 +- lints/0017_foreign_table_in_api.sql | 2 +- lints/0019_insecure_queue_exposed_in_api.sql | 2 +- lints/0023_sensitive_columns_exposed.sql | 2 +- ...on_security_definer_function_executable.sql | 2 +- ...ed_security_definer_function_executable.sql | 2 +- splinter.sql | 18 +++++++++--------- test/expected/0013_rls_disabled_in_public.out | 15 +++++++++++++++ test/sql/0013_rls_disabled_in_public.sql | 14 ++++++++++++++ 13 files changed, 59 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 32c2363c..023ed88e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,18 @@ Splinter maintains a set of lints for Supabase projects. It uses SQL queries to If you are only interested in linting a project, a single query containing the latest version of all lints is availble in splinter.sql in the repo root. +### API-exposure lints and `pgrst.db_schemas` + +Several lints (e.g. `auth_users_exposed`, `materialized_view_in_api`, `foreign_table_in_api`) only report objects that are reachable through the Data API — that is, objects in a schema listed in the `pgrst.db_schemas` setting. PostgREST sets this value at runtime, but it is **not** present in a plain `psql` connection. + +When `pgrst.db_schemas` is unset, these lints default to the **`public` schema only**. If your project exposes additional schemas, set the value to match your PostgREST config before running the lints so those schemas are covered too: + +```sql +set pgrst.db_schemas = 'public, graphql_public, ...'; +``` + +Without this, exposures in non-`public` schemas will not be reported. + ## Lint Interface Each lint creates a view that returns a common interface. The interface is: diff --git a/lints/0002_auth_users_exposed.sql b/lints/0002_auth_users_exposed.sql index c9a2b474..b903e71b 100644 --- a/lints/0002_auth_users_exposed.sql +++ b/lints/0002_auth_users_exposed.sql @@ -43,7 +43,7 @@ where pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) -- Exclude self and c.relname <> '0002_auth_users_exposed' -- There are 3 insecure configurations diff --git a/lints/0010_security_definer_view.sql b/lints/0010_security_definer_view.sql index d523744b..2bbcf50e 100644 --- a/lints/0010_security_definer_view.sql +++ b/lints/0010_security_definer_view.sql @@ -38,7 +38,7 @@ where or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15 - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) diff --git a/lints/0013_rls_disabled_in_public.sql b/lints/0013_rls_disabled_in_public.sql index cd7ed4c2..e83d500b 100644 --- a/lints/0013_rls_disabled_in_public.sql +++ b/lints/0013_rls_disabled_in_public.sql @@ -35,7 +35,7 @@ where pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ); diff --git a/lints/0016_materialized_view_in_api.sql b/lints/0016_materialized_view_in_api.sql index cb7ec799..24b7743e 100644 --- a/lints/0016_materialized_view_in_api.sql +++ b/lints/0016_materialized_view_in_api.sql @@ -37,7 +37,7 @@ where pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) diff --git a/lints/0017_foreign_table_in_api.sql b/lints/0017_foreign_table_in_api.sql index 6a96b8bd..0099e934 100644 --- a/lints/0017_foreign_table_in_api.sql +++ b/lints/0017_foreign_table_in_api.sql @@ -37,7 +37,7 @@ where pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) diff --git a/lints/0019_insecure_queue_exposed_in_api.sql b/lints/0019_insecure_queue_exposed_in_api.sql index 2c26cc44..89320003 100644 --- a/lints/0019_insecure_queue_exposed_in_api.sql +++ b/lints/0019_insecure_queue_exposed_in_api.sql @@ -37,4 +37,4 @@ where and n.nspname = 'pgmq' -- tables in the pgmq schema and c.relname like 'q_%' -- only queue tables -- Constant requirements - and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and 'pgmq_public' = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) diff --git a/lints/0023_sensitive_columns_exposed.sql b/lints/0023_sensitive_columns_exposed.sql index a0b73256..3e2d12bb 100644 --- a/lints/0023_sensitive_columns_exposed.sql +++ b/lints/0023_sensitive_columns_exposed.sql @@ -48,7 +48,7 @@ exposed_tables as ( pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) diff --git a/lints/0028_anon_security_definer_function_executable.sql b/lints/0028_anon_security_definer_function_executable.sql index d80bfa74..ac47354a 100644 --- a/lints/0028_anon_security_definer_function_executable.sql +++ b/lints/0028_anon_security_definer_function_executable.sql @@ -61,7 +61,7 @@ from where p.prosecdef = true and pg_catalog.has_function_privilege('anon', p.oid, 'EXECUTE') - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) diff --git a/lints/0029_authenticated_security_definer_function_executable.sql b/lints/0029_authenticated_security_definer_function_executable.sql index ee14d96d..a0008957 100644 --- a/lints/0029_authenticated_security_definer_function_executable.sql +++ b/lints/0029_authenticated_security_definer_function_executable.sql @@ -62,7 +62,7 @@ from where p.prosecdef = true and pg_catalog.has_function_privilege('authenticated', p.oid, 'EXECUTE') - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) diff --git a/splinter.sql b/splinter.sql index 876d9cf9..a5a51943 100644 --- a/splinter.sql +++ b/splinter.sql @@ -121,7 +121,7 @@ where pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) -- Exclude self and c.relname <> '0002_auth_users_exposed' -- There are 3 insecure configurations @@ -621,7 +621,7 @@ where or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' -- security invoker was added in pg15 - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) @@ -717,7 +717,7 @@ where pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' )) @@ -849,7 +849,7 @@ where pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) @@ -893,7 +893,7 @@ where pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) @@ -980,7 +980,7 @@ where and n.nspname = 'pgmq' -- tables in the pgmq schema and c.relname like 'q_%' -- only queue tables -- Constant requirements - and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))) + and 'pgmq_public' = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))) union all ( with constants as ( @@ -1212,7 +1212,7 @@ exposed_tables as ( pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') ) - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) @@ -1743,7 +1743,7 @@ from where p.prosecdef = true and pg_catalog.has_function_privilege('anon', p.oid, 'EXECUTE') - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) @@ -1816,7 +1816,7 @@ from where p.prosecdef = true and pg_catalog.has_function_privilege('authenticated', p.oid, 'EXECUTE') - and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ','))))) and n.nspname not in ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' ) diff --git a/test/expected/0013_rls_disabled_in_public.out b/test/expected/0013_rls_disabled_in_public.out index 17ddf41f..8220417d 100644 --- a/test/expected/0013_rls_disabled_in_public.out +++ b/test/expected/0013_rls_disabled_in_public.out @@ -1,3 +1,18 @@ +-- PSQL-1309: lint must still fire when `pgrst.db_schemas` is unset (e.g. running +-- splinter.sql directly via psql), defaulting to the `public` schema. This block +-- runs first, in a fresh session where the GUC has never been set (NULL). +begin; + set local search_path = ''; + -- pgrst.db_schemas intentionally NOT set + create table public.unset_guc_tbl( it int primary key ); + -- 1 issue (defaults to the `public` schema) + select * from lint."0013_rls_disabled_in_public"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------------------------+------------------------+-------+----------+------------+-------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------------+--------------------------------------------------------------------------------------------+----------------------------------------------------------------+--------------------------------------------- + rls_disabled_in_public | RLS Disabled in Public | ERROR | EXTERNAL | {SECURITY} | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \`public.unset_guc_tbl\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public | {"name": "unset_guc_tbl", "type": "table", "schema": "public"} | rls_disabled_in_public_public_unset_guc_tbl +(1 row) + +rollback; begin; set local search_path = ''; set local pgrst.db_schemas = 'public'; diff --git a/test/sql/0013_rls_disabled_in_public.sql b/test/sql/0013_rls_disabled_in_public.sql index b3d261cf..51f91d68 100644 --- a/test/sql/0013_rls_disabled_in_public.sql +++ b/test/sql/0013_rls_disabled_in_public.sql @@ -1,3 +1,17 @@ +-- PSQL-1309: lint must still fire when `pgrst.db_schemas` is unset (e.g. running +-- splinter.sql directly via psql), defaulting to the `public` schema. This block +-- runs first, in a fresh session where the GUC has never been set (NULL). +begin; + set local search_path = ''; + -- pgrst.db_schemas intentionally NOT set + + create table public.unset_guc_tbl( it int primary key ); + + -- 1 issue (defaults to the `public` schema) + select * from lint."0013_rls_disabled_in_public"; +rollback; + + begin; set local search_path = ''; set local pgrst.db_schemas = 'public';