Skip to content

Commit

Permalink
add lint for securing queues over data apis
Browse files Browse the repository at this point in the history
  • Loading branch information
olirice committed Dec 4, 2024
1 parent 4be470c commit 16233dd
Show file tree
Hide file tree
Showing 6 changed files with 246 additions and 2 deletions.
38 changes: 38 additions & 0 deletions docs/0019_insecure_queue_exposed_in_api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@

Level: ERROR

### Rationale

Queues exposed over Data APIs must be secured by Postgres permissions or row level security (RLS). Without this protection, anyone with a project's URL can manipulate queue data. That is a critically unsafe configuration.

### How to Resolve

To secure a queue, enable RLS on the queue's underlying table `pgmq.q_<queue_name>`:

```sql
alter table pgmq.q_<queue_name> enable row level security;
```

Note that after enabling RLS you will not be able to access data in the queue over APIs until you create [row level security policies](https://supabase.com/docs/guides/auth/row-level-security) to control access.

### Example

Given a queue named `foo` and underlying table `pgmq.q_foo`:

```sql
create table pgmq.q_foo(
msg_id bigint generated always as identity,
read_ct int default 0 not null,
enqueued_at timestamp with timezone default now() not null,
vt timestamp with time zone not null,
message jsonb
);
```

If Data APIs are enabled, and `anon` or `authenticated` have permissions on the table, any user with access to the project's URL and public API key will be able to manipulate messages in that Queue. To restrict access to users specified in row level security policies, enable RLS with:

```sql
alter table pgmq.q_foo enable row level security;
```

If queuesa are not being accessed through data APIs, an alternative is to remove the `pgmq_public` schema from the [Exposed schemas in API settings](https://supabase.com/dashboard/project/_/settings/api). That change secures your project by making all queues inaccessible over APIs.
40 changes: 40 additions & 0 deletions lints/0019_insecure_queue_exposed_in_api.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
create view lint."0019_insecure_queue_exposed_in_api" as

select
'insecure_queue_exposed_in_api' as name,
'Insecure Queue Exposed in API' as title,
'ERROR' as level,
'EXTERNAL' as facing,
array['SECURITY'] as categories,
'Detects cases where an insecure Queue is exposed over Data APIs' as description,
format(
'Table \`%s.%s\` is public, but RLS has not been enabled.',
n.nspname,
c.relname
) as detail,
'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as remediation,
jsonb_build_object(
'schema', n.nspname,
'name', c.relname,
'type', 'table'
) as metadata,
format(
'rls_disabled_in_public_%s_%s',
n.nspname,
c.relname
) as cache_key
from
pg_catalog.pg_class c
join pg_catalog.pg_namespace n
on c.relnamespace = n.oid
where
c.relkind in ('r', 'I') -- regular or partitioned tables
and not c.relrowsecurity -- RLS is disabled
and (
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
)
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'), ',')))))
3 changes: 2 additions & 1 deletion splinter.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@
"0015_rls_references_user_metadata": "(\nwith policies as (\n select\n nsp.nspname as schema_name,\n pb.tablename as table_name,\n polname as policy_name,\n qual,\n with_check\n from\n pg_catalog.pg_policy pa\n join pg_catalog.pg_class pc\n on pa.polrelid = pc.oid\n join pg_catalog.pg_namespace nsp\n on pc.relnamespace = nsp.oid\n join pg_catalog.pg_policies pb\n on pc.relname = pb.tablename\n and nsp.nspname = pb.schemaname\n and pa.polname = pb.policyname\n)\nselect\n 'rls_references_user_metadata' as name,\n 'RLS references user metadata' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects when Supabase Auth user_metadata is referenced insecurely in a row level security (RLS) policy.' as description,\n format(\n 'Table \\`%s.%s\\` has a row level security policy \\`%s\\` that references Supabase Auth \\`user_metadata\\`. \\`user_metadata\\` is editable by end users and should never be used in a security context.',\n schema_name,\n table_name,\n policy_name\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0015_rls_references_user_metadata' as remediation,\n jsonb_build_object(\n 'schema', schema_name,\n 'name', table_name,\n 'type', 'table'\n ) as metadata,\n format('rls_references_user_metadata_%s_%s_%s', schema_name, table_name, policy_name) as cache_key\nfrom\n policies\nwhere\n schema_name not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and (\n -- Example: auth.jwt() -> 'user_metadata'\n -- False positives are possible, but it isn't practical to string match\n -- If false positive rate is too high, this expression can iterate\n qual like '%auth.jwt()%user_metadata%'\n or qual like '%current_setting(%request.jwt.claims%)%user_metadata%'\n or with_check like '%auth.jwt()%user_metadata%'\n or with_check like '%current_setting(%request.jwt.claims%)%user_metadata%'\n ))",
"0016_materialized_view_in_api": "(\nselect\n 'materialized_view_in_api' as name,\n 'Materialized View in API' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects materialized views that are accessible over the Data APIs.' as description,\n format(\n 'Materialized view \\`%s.%s\\` is selectable by anon or authenticated roles',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'materialized view'\n ) as metadata,\n format(\n 'materialized_view_in_api_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'm'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null)",
"0017_foreign_table_in_api": "(\nselect\n 'foreign_table_in_api' as name,\n 'Foreign Table in API' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects foreign tables that are accessible over APIs. Foreign tables do not respect row level security policies.' as description,\n format(\n 'Foreign table \\`%s.%s\\` is accessible over APIs',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'foreign table'\n ) as metadata,\n format(\n 'foreign_table_in_api_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\n left join pg_catalog.pg_depend dep\n on c.oid = dep.objid\n and dep.deptype = 'e'\nwhere\n c.relkind = 'f'\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ',')))))\n and n.nspname not in (\n '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'\n )\n and dep.objid is null)",
"0018_unsupported_reg_types": "(\nselect\n 'unsupported_reg_types' as name,\n 'Unsupported reg types' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.' as description,\n format(\n 'Table \\`%s.%s\\` has a column \\`%s\\` with unsupported reg* type \\`%s\\`.',\n n.nspname,\n c.relname,\n a.attname,\n t.typname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'column', a.attname,\n 'type', 'table'\n ) as metadata,\n format(\n 'unsupported_reg_types_%s_%s_%s',\n n.nspname,\n c.relname,\n a.attname\n ) AS cache_key\nfrom\n pg_catalog.pg_attribute a\n join pg_catalog.pg_class c\n on a.attrelid = c.oid\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n join pg_catalog.pg_type t\n on a.atttypid = t.oid\n join pg_catalog.pg_namespace tn\n on t.typnamespace = tn.oid\nwhere\n tn.nspname = 'pg_catalog'\n and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure')\n and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium'))"
"0018_unsupported_reg_types": "(\nselect\n 'unsupported_reg_types' as name,\n 'Unsupported reg types' as title,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Identifies columns using unsupported reg* types outside pg_catalog schema, which prevents database upgrades using pg_upgrade.' as description,\n format(\n 'Table \\`%s.%s\\` has a column \\`%s\\` with unsupported reg* type \\`%s\\`.',\n n.nspname,\n c.relname,\n a.attname,\n t.typname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=unsupported_reg_types' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'column', a.attname,\n 'type', 'table'\n ) as metadata,\n format(\n 'unsupported_reg_types_%s_%s_%s',\n n.nspname,\n c.relname,\n a.attname\n ) AS cache_key\nfrom\n pg_catalog.pg_attribute a\n join pg_catalog.pg_class c\n on a.attrelid = c.oid\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\n join pg_catalog.pg_type t\n on a.atttypid = t.oid\n join pg_catalog.pg_namespace tn\n on t.typnamespace = tn.oid\nwhere\n tn.nspname = 'pg_catalog'\n and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure')\n and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium'))",
"0019_insecure_queue_exposed_in_api": "(\nselect\n 'insecure_queue_exposed_in_api' as name,\n 'Insecure Queue Exposed in API' as title,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n array['SECURITY'] as categories,\n 'Detects cases where an insecure Queue is exposed over Data APIs' as description,\n format(\n 'Table \\`%s.%s\\` is public, but RLS has not been enabled.',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'rls_disabled_in_public_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\nwhere\n c.relkind in ('r', 'I') -- regular or partitioned tables\n and not c.relrowsecurity -- RLS is disabled\n and (\n pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')\n or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')\n )\n and n.nspname = 'pgmq' -- tables in the pgmq schema\n and c.relname like 'q_%' -- only queue tables\n -- Constant requirements\n and 'pgmq_public' = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))))"
}
42 changes: 41 additions & 1 deletion splinter.sql
Original file line number Diff line number Diff line change
Expand Up @@ -918,4 +918,44 @@ from
where
tn.nspname = 'pg_catalog'
and t.typname in ('regcollation', 'regconfig', 'regdictionary', 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure')
and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium'))
and n.nspname not in ('pg_catalog', 'information_schema', 'pgsodium'))
union all
(
select
'insecure_queue_exposed_in_api' as name,
'Insecure Queue Exposed in API' as title,
'ERROR' as level,
'EXTERNAL' as facing,
array['SECURITY'] as categories,
'Detects cases where an insecure Queue is exposed over Data APIs' as description,
format(
'Table \`%s.%s\` is public, but RLS has not been enabled.',
n.nspname,
c.relname
) as detail,
'https://supabase.com/docs/guides/database/database-linter?lint=0019_insecure_queue_exposed_in_api' as remediation,
jsonb_build_object(
'schema', n.nspname,
'name', c.relname,
'type', 'table'
) as metadata,
format(
'rls_disabled_in_public_%s_%s',
n.nspname,
c.relname
) as cache_key
from
pg_catalog.pg_class c
join pg_catalog.pg_namespace n
on c.relnamespace = n.oid
where
c.relkind in ('r', 'I') -- regular or partitioned tables
and not c.relrowsecurity -- RLS is disabled
and (
pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
)
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'), ','))))))
Loading

0 comments on commit 16233dd

Please sign in to comment.