From ee82f91dead592e1834610e3bb2f440e23c72c11 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:13:41 +1000 Subject: [PATCH 1/8] add public bucket listing lint --- bin/installcheck | 2 +- docs/0025_public_bucket_allows_listing.md | 62 ++++++++ lints/0025_public_bucket_allows_listing.sql | 123 +++++++++++++++ splinter.sql | 125 +++++++++++++++- .../0025_public_bucket_allows_listing.out | 137 +++++++++++++++++ test/expected/queries_are_unionable.out | 4 +- .../sql/0025_public_bucket_allows_listing.sql | 140 ++++++++++++++++++ test/sql/queries_are_unionable.sql | 4 +- 8 files changed, 593 insertions(+), 4 deletions(-) create mode 100644 docs/0025_public_bucket_allows_listing.md create mode 100644 lints/0025_public_bucket_allows_listing.sql create mode 100644 test/expected/0025_public_bucket_allows_listing.out create mode 100644 test/sql/0025_public_bucket_allows_listing.sql diff --git a/bin/installcheck b/bin/installcheck index 3ad5b366..6e64d935 100755 --- a/bin/installcheck +++ b/bin/installcheck @@ -52,7 +52,7 @@ else fi # Execute the test fixtures -psql -v ON_ERROR_STOP= -f test/fixtures.sql -f lints/0001*.sql -f lints/0002*.sql -f lints/0003*.sql -f lints/0004*.sql -f lints/0005*.sql -f lints/0006*.sql -f lints/0007*.sql -f lints/0008*.sql -f lints/0009*.sql -f lints/0010*.sql -f lints/0011*.sql -f lints/0013*.sql -f lints/0014*.sql -f lints/0015*.sql -f lints/0016*.sql -f lints/0017*.sql -f lints/0018*.sql -f lints/0019*.sql -f lints/0020*.sql -f lints/0021*.sql -f lints/0022*.sql -f lints/0023*.sql -f lints/0024*.sql -d contrib_regression +psql -v ON_ERROR_STOP= -f test/fixtures.sql -f lints/0001*.sql -f lints/0002*.sql -f lints/0003*.sql -f lints/0004*.sql -f lints/0005*.sql -f lints/0006*.sql -f lints/0007*.sql -f lints/0008*.sql -f lints/0009*.sql -f lints/0010*.sql -f lints/0011*.sql -f lints/0013*.sql -f lints/0014*.sql -f lints/0015*.sql -f lints/0016*.sql -f lints/0017*.sql -f lints/0018*.sql -f lints/0019*.sql -f lints/0020*.sql -f lints/0021*.sql -f lints/0022*.sql -f lints/0023*.sql -f lints/0024*.sql -f lints/0025*.sql -d contrib_regression # Run tests ${REGRESS} --use-existing --dbname=contrib_regression --inputdir=${TESTDIR} ${TESTS} diff --git a/docs/0025_public_bucket_allows_listing.md b/docs/0025_public_bucket_allows_listing.md new file mode 100644 index 00000000..17653a09 --- /dev/null +++ b/docs/0025_public_bucket_allows_listing.md @@ -0,0 +1,62 @@ +**Level:** WARN + +**Summary:** Detects public storage buckets whose `SELECT` policies on `storage.objects` make their contents listable. + +**Ramification:** Clients can enumerate the files in a public bucket, which often exposes more information than intended even though public object URLs would still work without the policy. + +--- + +### Rationale + +Supabase public buckets are already readable by URL. They do not need a `SELECT` policy on `storage.objects` for clients to fetch known object paths. + +The footgun appears when a public bucket also has one or more bucket-scoped `SELECT` policies on `storage.objects`, for example `bucket_id = 'avatars'`. That combination allows clients to list objects in the bucket through Storage APIs, which is often broader access than the project intended. + +This lint is intentionally narrow. It does not warn on all public buckets. It only warns when a public bucket also has a matching `SELECT` policy that makes its contents enumerable. + +### How to Resolve + +**Option 1: Remove the unnecessary `SELECT` policy** + +```sql +drop policy if exists "Public bucket listing" on storage.objects; +``` + +Object URLs for the public bucket will continue to work after removing the `SELECT` policy. + +**Option 2: Make the bucket private if listing is actually required** + +```sql +update storage.buckets +set public = false +where id = 'avatars'; +``` + +Use private bucket access patterns if the project truly needs authenticated listing behavior. + +### Example + +Given this problematic configuration: + +```sql +insert into storage.buckets (id, name, public) +values ('avatars', 'avatars', true); + +create policy "Public bucket listing" +on storage.objects +for select +to authenticated +using (bucket_id = 'avatars'); +``` + +Fix: + +```sql +drop policy if exists "Public bucket listing" on storage.objects; +``` + +### False Positives + +This lint may fire when bucket listing is intentional for a public bucket. In that case, keep the policy and handle the warning as an accepted risk. + +The lint is also intentionally conservative. It only detects direct bucket-specific `bucket_id = ''` matches, so more complex policy expressions may not be reported. diff --git a/lints/0025_public_bucket_allows_listing.sql b/lints/0025_public_bucket_allows_listing.sql new file mode 100644 index 00000000..7eda787e --- /dev/null +++ b/lints/0025_public_bucket_allows_listing.sql @@ -0,0 +1,123 @@ +create view lint."0025_public_bucket_allows_listing" as + +with storage_bucket_table as ( + select + 1 + from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + where + n.nspname = 'storage' + and c.relname = 'buckets' + and c.relkind in ('r', 'p') +), +public_buckets as ( + -- Read storage.buckets at runtime so the lint can load even when storage is not installed. + select + bucket_id, + bucket_name, + replace( + replace( + replace( + replace( + replace( + replace( + replace(pg_catalog.quote_literal(bucket_id), '.', E'\\.'), + '*', + E'\\*' + ), + '(', + E'\\(' + ), + ')', + E'\\)' + ), + '$', + E'\\$' + ), + '+', + E'\\+' + ), + '?', + E'\\?' + ) as quoted_bucket_pattern + from + ( + select + (xpath('/row/id/text()', bucket_xml))[1]::text as bucket_id, + (xpath('/row/name/text()', bucket_xml))[1]::text as bucket_name + from + storage_bucket_table + cross join lateral unnest( + xpath( + '/table/row', + pg_catalog.query_to_xml( + 'select id, name from storage.buckets where public = true order by id', + false, + false, + '' + ) + ) + ) as bucket_rows(bucket_xml) + ) public_bucket_rows +), +matching_policies as ( + select + b.bucket_id, + b.bucket_name, + p.policyname + from + public_buckets b + join pg_catalog.pg_policies p + on p.schemaname = 'storage' + and p.tablename = 'objects' + and p.cmd = 'SELECT' + where + p.qual ~* (E'bucket_id\\s*=\\s*' || b.quoted_bucket_pattern) +), +affected_buckets as ( + select + bucket_id, + bucket_name, + array_agg(policyname order by policyname) as policy_names, + count(*)::int as policy_count + from + matching_policies + group by + bucket_id, + bucket_name +) +select + 'public_bucket_allows_listing' as name, + 'Public Bucket Allows Listing' as title, + 'WARN' as level, + 'EXTERNAL' as facing, + array['SECURITY'] as categories, + 'Detects public storage buckets that also have bucket-specific SELECT policies on storage.objects. Public buckets do not require SELECT policies for object URL access, and adding them can unintentionally make bucket contents listable.' as description, + format( + 'Public storage bucket `%s` (`%s`) has %s matching SELECT %s on `storage.objects`: %s. This allows clients to list the bucket contents. Public buckets do not require SELECT policies for object URL access, and this is often unintentional.', + bucket_name, + bucket_id, + policy_count, + case + when policy_count = 1 then 'policy' + else 'policies' + end, + array_to_string(policy_names, ', ') + ) as detail, + 'https://supabase.com/docs/guides/database/database-linter?lint=0025_public_bucket_allows_listing' as remediation, + jsonb_build_object( + 'schema', 'storage', + 'name', bucket_name, + 'type', 'bucket', + 'bucket_id', bucket_id, + 'bucket_name', bucket_name, + 'policy_names', policy_names, + 'policy_count', policy_count + ) as metadata, + format('public_bucket_allows_listing_%s', bucket_id) as cache_key +from + affected_buckets +order by + bucket_id; diff --git a/splinter.sql b/splinter.sql index 14cba60b..822c5623 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1383,4 +1383,127 @@ where order by schema_name, table_name, - policy_name) \ No newline at end of file + policy_name) +union all +( +with storage_bucket_table as ( + select + 1 + from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + where + n.nspname = 'storage' + and c.relname = 'buckets' + and c.relkind in ('r', 'p') +), +public_buckets as ( + -- Read storage.buckets at runtime so the lint can load even when storage is not installed. + select + bucket_id, + bucket_name, + replace( + replace( + replace( + replace( + replace( + replace( + replace(pg_catalog.quote_literal(bucket_id), '.', E'\\.'), + '*', + E'\\*' + ), + '(', + E'\\(' + ), + ')', + E'\\)' + ), + '$', + E'\\$' + ), + '+', + E'\\+' + ), + '?', + E'\\?' + ) as quoted_bucket_pattern + from + ( + select + (xpath('/row/id/text()', bucket_xml))[1]::text as bucket_id, + (xpath('/row/name/text()', bucket_xml))[1]::text as bucket_name + from + storage_bucket_table + cross join lateral unnest( + xpath( + '/table/row', + pg_catalog.query_to_xml( + 'select id, name from storage.buckets where public = true order by id', + false, + false, + '' + ) + ) + ) as bucket_rows(bucket_xml) + ) public_bucket_rows +), +matching_policies as ( + select + b.bucket_id, + b.bucket_name, + p.policyname + from + public_buckets b + join pg_catalog.pg_policies p + on p.schemaname = 'storage' + and p.tablename = 'objects' + and p.cmd = 'SELECT' + where + p.qual ~* (E'bucket_id\\s*=\\s*' || b.quoted_bucket_pattern) +), +affected_buckets as ( + select + bucket_id, + bucket_name, + array_agg(policyname order by policyname) as policy_names, + count(*)::int as policy_count + from + matching_policies + group by + bucket_id, + bucket_name +) +select + 'public_bucket_allows_listing' as name, + 'Public Bucket Allows Listing' as title, + 'WARN' as level, + 'EXTERNAL' as facing, + array['SECURITY'] as categories, + 'Detects public storage buckets that also have bucket-specific SELECT policies on storage.objects. Public buckets do not require SELECT policies for object URL access, and adding them can unintentionally make bucket contents listable.' as description, + format( + 'Public storage bucket `%s` (`%s`) has %s matching SELECT %s on `storage.objects`: %s. This allows clients to list the bucket contents. Public buckets do not require SELECT policies for object URL access, and this is often unintentional.', + bucket_name, + bucket_id, + policy_count, + case + when policy_count = 1 then 'policy' + else 'policies' + end, + array_to_string(policy_names, ', ') + ) as detail, + 'https://supabase.com/docs/guides/database/database-linter?lint=0025_public_bucket_allows_listing' as remediation, + jsonb_build_object( + 'schema', 'storage', + 'name', bucket_name, + 'type', 'bucket', + 'bucket_id', bucket_id, + 'bucket_name', bucket_name, + 'policy_names', policy_names, + 'policy_count', policy_count + ) as metadata, + format('public_bucket_allows_listing_%s', bucket_id) as cache_key +from + affected_buckets +order by + bucket_id) \ No newline at end of file diff --git a/test/expected/0025_public_bucket_allows_listing.out b/test/expected/0025_public_bucket_allows_listing.out new file mode 100644 index 00000000..208379b6 --- /dev/null +++ b/test/expected/0025_public_bucket_allows_listing.out @@ -0,0 +1,137 @@ +begin; + set local search_path = ''; + -- BASELINE: 0 issues before storage is installed + select * from lint."0025_public_bucket_allows_listing"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + create schema storage; + create table storage.buckets( + id text primary key, + name text not null, + public boolean not null default false + ); + create table storage.objects( + bucket_id text not null, + name text not null + ); + alter table storage.objects enable row level security; + savepoint a; + -- NEGATIVE EXAMPLE: a public bucket without a matching SELECT policy should not fire + -- Public buckets can rely on object URLs alone, and INSERT policies do not make contents listable + insert into storage.buckets(id, name, public) + values ('public-without-policy', 'Public without policy', true); + create policy "public_without_policy_insert" + on storage.objects + for insert + to authenticated + with check (bucket_id = 'public-without-policy'); + select * from lint."0025_public_bucket_allows_listing"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint a; + savepoint b; + -- POSITIVE EXAMPLE: a public bucket with a matching SELECT policy should fire once + -- The SELECT policy references the public bucket directly, so clients can list its contents + insert into storage.buckets(id, name, public) + values ('listable.bucket+1', 'Listable bucket', true); + create policy "listable_bucket_select" + on storage.objects + for select + to authenticated + using (bucket_id = 'listable.bucket+1'); + select + name, + metadata->>'bucket_id' as bucket_id, + metadata->>'bucket_name' as bucket_name, + metadata->>'policy_count' as policy_count, + metadata->'policy_names' as policy_names, + cache_key + from lint."0025_public_bucket_allows_listing"; + name | bucket_id | bucket_name | policy_count | policy_names | cache_key +------------------------------+-------------------+-----------------+--------------+----------------------------+------------------------------------------------ + public_bucket_allows_listing | listable.bucket+1 | Listable bucket | 1 | ["listable_bucket_select"] | public_bucket_allows_listing_listable.bucket+1 +(1 row) + + -- RESOLUTION: removing the unnecessary SELECT policy should clear the lint + drop policy "listable_bucket_select" on storage.objects; + select * from lint."0025_public_bucket_allows_listing"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint b; + savepoint c; + -- MULTIPLE POLICIES: the bucket should still produce a single lint with aggregated metadata + -- Both SELECT policies target the same public bucket, so the lint should collapse them into one result + insert into storage.buckets(id, name, public) + values ('multi-policy-bucket', 'Multi policy bucket', true); + create policy "bucket_listing_policy_a" + on storage.objects + for select + to authenticated + using (bucket_id = 'multi-policy-bucket'); + create policy "bucket_listing_policy_b" + on storage.objects + for select + to authenticated + using (bucket_id = 'multi-policy-bucket' and name like 'public/%'); + select + metadata->>'bucket_id' as bucket_id, + metadata->>'policy_count' as policy_count, + metadata->'policy_names' as policy_names + from lint."0025_public_bucket_allows_listing"; + bucket_id | policy_count | policy_names +---------------------+--------------+-------------------------------------------------------- + multi-policy-bucket | 2 | ["bucket_listing_policy_a", "bucket_listing_policy_b"] +(1 row) + + rollback to savepoint c; + savepoint d; + -- PRIVATE BUCKET: matching SELECT policy text alone should not fire + -- Private buckets are out of scope for this lint even when the policy text looks similar + insert into storage.buckets(id, name, public) + values ('private-bucket', 'Private bucket', false); + create policy "private_bucket_select" + on storage.objects + for select + to authenticated + using (bucket_id = 'private-bucket'); + select * from lint."0025_public_bucket_allows_listing"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint d; + savepoint e; + -- MULTIPLE AFFECTED BUCKETS: each affected public bucket should produce its own lint + -- Two public buckets each have their own matching SELECT policy, so the lint should emit one row per bucket + insert into storage.buckets(id, name, public) + values + ('alpha-bucket', 'Alpha bucket', true), + ('omega-bucket', 'Omega bucket', true); + create policy "alpha_bucket_select" + on storage.objects + for select + to authenticated + using (bucket_id = 'alpha-bucket'); + create policy "omega_bucket_select" + on storage.objects + for select + to authenticated + using (bucket_id = 'omega-bucket'); + select + metadata->>'bucket_id' as bucket_id, + cache_key + from lint."0025_public_bucket_allows_listing"; + bucket_id | cache_key +--------------+------------------------------------------- + alpha-bucket | public_bucket_allows_listing_alpha-bucket + omega-bucket | public_bucket_allows_listing_omega-bucket +(2 rows) + + rollback to savepoint e; +rollback; diff --git a/test/expected/queries_are_unionable.out b/test/expected/queries_are_unionable.out index b745bf6a..1566b597 100644 --- a/test/expected/queries_are_unionable.out +++ b/test/expected/queries_are_unionable.out @@ -44,7 +44,9 @@ begin; union all select * from lint."0023_sensitive_columns_exposed" union all - select * from lint."0024_rls_policy_always_true"; + select * from lint."0024_rls_policy_always_true" + union all + select * from lint."0025_public_bucket_allows_listing"; name | title | level | facing | categories | description | detail | remediation | metadata | cache_key ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) diff --git a/test/sql/0025_public_bucket_allows_listing.sql b/test/sql/0025_public_bucket_allows_listing.sql new file mode 100644 index 00000000..e49c6f27 --- /dev/null +++ b/test/sql/0025_public_bucket_allows_listing.sql @@ -0,0 +1,140 @@ +begin; + set local search_path = ''; + + -- BASELINE: 0 issues before storage is installed + select * from lint."0025_public_bucket_allows_listing"; + + create schema storage; + + create table storage.buckets( + id text primary key, + name text not null, + public boolean not null default false + ); + + create table storage.objects( + bucket_id text not null, + name text not null + ); + + alter table storage.objects enable row level security; + + savepoint a; + + -- NEGATIVE EXAMPLE: a public bucket without a matching SELECT policy should not fire + -- Public buckets can rely on object URLs alone, and INSERT policies do not make contents listable + insert into storage.buckets(id, name, public) + values ('public-without-policy', 'Public without policy', true); + + create policy "public_without_policy_insert" + on storage.objects + for insert + to authenticated + with check (bucket_id = 'public-without-policy'); + + select * from lint."0025_public_bucket_allows_listing"; + + rollback to savepoint a; + + savepoint b; + + -- POSITIVE EXAMPLE: a public bucket with a matching SELECT policy should fire once + -- The SELECT policy references the public bucket directly, so clients can list its contents + insert into storage.buckets(id, name, public) + values ('listable.bucket+1', 'Listable bucket', true); + + create policy "listable_bucket_select" + on storage.objects + for select + to authenticated + using (bucket_id = 'listable.bucket+1'); + + select + name, + metadata->>'bucket_id' as bucket_id, + metadata->>'bucket_name' as bucket_name, + metadata->>'policy_count' as policy_count, + metadata->'policy_names' as policy_names, + cache_key + from lint."0025_public_bucket_allows_listing"; + + -- RESOLUTION: removing the unnecessary SELECT policy should clear the lint + drop policy "listable_bucket_select" on storage.objects; + + select * from lint."0025_public_bucket_allows_listing"; + + rollback to savepoint b; + + savepoint c; + + -- MULTIPLE POLICIES: the bucket should still produce a single lint with aggregated metadata + -- Both SELECT policies target the same public bucket, so the lint should collapse them into one result + insert into storage.buckets(id, name, public) + values ('multi-policy-bucket', 'Multi policy bucket', true); + + create policy "bucket_listing_policy_a" + on storage.objects + for select + to authenticated + using (bucket_id = 'multi-policy-bucket'); + + create policy "bucket_listing_policy_b" + on storage.objects + for select + to authenticated + using (bucket_id = 'multi-policy-bucket' and name like 'public/%'); + + select + metadata->>'bucket_id' as bucket_id, + metadata->>'policy_count' as policy_count, + metadata->'policy_names' as policy_names + from lint."0025_public_bucket_allows_listing"; + + rollback to savepoint c; + + savepoint d; + + -- PRIVATE BUCKET: matching SELECT policy text alone should not fire + -- Private buckets are out of scope for this lint even when the policy text looks similar + insert into storage.buckets(id, name, public) + values ('private-bucket', 'Private bucket', false); + + create policy "private_bucket_select" + on storage.objects + for select + to authenticated + using (bucket_id = 'private-bucket'); + + select * from lint."0025_public_bucket_allows_listing"; + + rollback to savepoint d; + + savepoint e; + + -- MULTIPLE AFFECTED BUCKETS: each affected public bucket should produce its own lint + -- Two public buckets each have their own matching SELECT policy, so the lint should emit one row per bucket + insert into storage.buckets(id, name, public) + values + ('alpha-bucket', 'Alpha bucket', true), + ('omega-bucket', 'Omega bucket', true); + + create policy "alpha_bucket_select" + on storage.objects + for select + to authenticated + using (bucket_id = 'alpha-bucket'); + + create policy "omega_bucket_select" + on storage.objects + for select + to authenticated + using (bucket_id = 'omega-bucket'); + + select + metadata->>'bucket_id' as bucket_id, + cache_key + from lint."0025_public_bucket_allows_listing"; + + rollback to savepoint e; + +rollback; diff --git a/test/sql/queries_are_unionable.sql b/test/sql/queries_are_unionable.sql index f493a07e..f08a0376 100644 --- a/test/sql/queries_are_unionable.sql +++ b/test/sql/queries_are_unionable.sql @@ -46,6 +46,8 @@ begin; union all select * from lint."0023_sensitive_columns_exposed" union all - select * from lint."0024_rls_policy_always_true"; + select * from lint."0024_rls_policy_always_true" + union all + select * from lint."0025_public_bucket_allows_listing"; rollback; From c70723c3fb9cfed43aedfb250494b1c782cbd4cb Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:50:39 +1000 Subject: [PATCH 2/8] improve description --- .pre-commit-config.yaml | 2 +- lints/0025_public_bucket_allows_listing.sql | 5 ++--- splinter.sql | 5 ++--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 56b76833..840e776f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,6 +26,6 @@ repos: - id: compile-script name: Compile SQL Files entry: python bin/compile.py - language: system + language: python always_run: true pass_filenames: false diff --git a/lints/0025_public_bucket_allows_listing.sql b/lints/0025_public_bucket_allows_listing.sql index 7eda787e..c3f29137 100644 --- a/lints/0025_public_bucket_allows_listing.sql +++ b/lints/0025_public_bucket_allows_listing.sql @@ -94,11 +94,10 @@ select 'WARN' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects public storage buckets that also have bucket-specific SELECT policies on storage.objects. Public buckets do not require SELECT policies for object URL access, and adding them can unintentionally make bucket contents listable.' as description, + 'Detects public storage buckets with a SELECT policy on `storage.objects`, which allows clients to list all files in the bucket.' as description, format( - 'Public storage bucket `%s` (`%s`) has %s matching SELECT %s on `storage.objects`: %s. This allows clients to list the bucket contents. Public buckets do not require SELECT policies for object URL access, and this is often unintentional.', + 'Public bucket `%s` has %s SELECT %s on `storage.objects` (%s), allowing clients to list all files. Public buckets don''t need this and it may expose more data than intended.', bucket_name, - bucket_id, policy_count, case when policy_count = 1 then 'policy' diff --git a/splinter.sql b/splinter.sql index 822c5623..3a0fe972 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1480,11 +1480,10 @@ select 'WARN' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects public storage buckets that also have bucket-specific SELECT policies on storage.objects. Public buckets do not require SELECT policies for object URL access, and adding them can unintentionally make bucket contents listable.' as description, + 'Detects public storage buckets with a SELECT policy on `storage.objects`, which allows clients to list all files in the bucket.' as description, format( - 'Public storage bucket `%s` (`%s`) has %s matching SELECT %s on `storage.objects`: %s. This allows clients to list the bucket contents. Public buckets do not require SELECT policies for object URL access, and this is often unintentional.', + 'Public bucket `%s` has %s SELECT %s on `storage.objects` (%s), allowing clients to list all files. Public buckets don''t need this and it may expose more data than intended.', bucket_name, - bucket_id, policy_count, case when policy_count = 1 then 'policy' From ac5d66f6d9a5b9d099d3861249aeaed2edd50b30 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:18:32 +1000 Subject: [PATCH 3/8] tighten public bucket listing lint --- docs/0025_public_bucket_allows_listing.md | 8 ++-- lints/0025_public_bucket_allows_listing.sql | 10 +++-- splinter.sql | 10 +++-- .../0025_public_bucket_allows_listing.out | 36 +++++++++++++----- .../sql/0025_public_bucket_allows_listing.sql | 37 ++++++++++++++----- 5 files changed, 71 insertions(+), 30 deletions(-) diff --git a/docs/0025_public_bucket_allows_listing.md b/docs/0025_public_bucket_allows_listing.md index 17653a09..ecde59f0 100644 --- a/docs/0025_public_bucket_allows_listing.md +++ b/docs/0025_public_bucket_allows_listing.md @@ -1,6 +1,6 @@ **Level:** WARN -**Summary:** Detects public storage buckets whose `SELECT` policies on `storage.objects` make their contents listable. +**Summary:** Detects public storage buckets whose bucket-only `SELECT` policies on `storage.objects` make their contents listable. **Ramification:** Clients can enumerate the files in a public bucket, which often exposes more information than intended even though public object URLs would still work without the policy. @@ -10,7 +10,7 @@ Supabase public buckets are already readable by URL. They do not need a `SELECT` policy on `storage.objects` for clients to fetch known object paths. -The footgun appears when a public bucket also has one or more bucket-scoped `SELECT` policies on `storage.objects`, for example `bucket_id = 'avatars'`. That combination allows clients to list objects in the bucket through Storage APIs, which is often broader access than the project intended. +The footgun appears when a public bucket also has one or more bucket-only `SELECT` policies on `storage.objects`, for example `bucket_id = 'avatars'`. That combination allows clients to list objects in the bucket through Storage APIs, which is often broader access than the project intended. This lint is intentionally narrow. It does not warn on all public buckets. It only warns when a public bucket also has a matching `SELECT` policy that makes its contents enumerable. @@ -57,6 +57,6 @@ drop policy if exists "Public bucket listing" on storage.objects; ### False Positives -This lint may fire when bucket listing is intentional for a public bucket. In that case, keep the policy and handle the warning as an accepted risk. +This lint may fire when broad bucket listing is intentional for a public bucket. In that case, keep the policy and handle the warning as an accepted risk. -The lint is also intentionally conservative. It only detects direct bucket-specific `bucket_id = ''` matches, so more complex policy expressions may not be reported. +The lint is also intentionally conservative. It only detects direct bucket-only `bucket_id = ''` matches. It does not warn on policies that add additional object, path, or user constraints such as `bucket_id = 'avatars' and owner = auth.uid()`. diff --git a/lints/0025_public_bucket_allows_listing.sql b/lints/0025_public_bucket_allows_listing.sql index c3f29137..d62bf9b4 100644 --- a/lints/0025_public_bucket_allows_listing.sql +++ b/lints/0025_public_bucket_allows_listing.sql @@ -74,7 +74,11 @@ matching_policies as ( and p.tablename = 'objects' and p.cmd = 'SELECT' where - p.qual ~* (E'bucket_id\\s*=\\s*' || b.quoted_bucket_pattern) + p.qual ~* ( + E'^\\s*\\(*\\s*bucket_id\\s*=\\s*' + || b.quoted_bucket_pattern + || E'(\\s*::\\s*[[:alnum:]_\\.]+)?\\s*\\)*\\s*$' + ) ), affected_buckets as ( select @@ -94,9 +98,9 @@ select 'WARN' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects public storage buckets with a SELECT policy on `storage.objects`, which allows clients to list all files in the bucket.' as description, + 'Detects public storage buckets with a bucket-only SELECT policy on `storage.objects`, which allows clients to list all files in the bucket.' as description, format( - 'Public bucket `%s` has %s SELECT %s on `storage.objects` (%s), allowing clients to list all files. Public buckets don''t need this and it may expose more data than intended.', + 'Public bucket `%s` has %s bucket-only SELECT %s on `storage.objects` (%s), allowing clients to list all files. Public buckets don''t need this for object URL access and it may expose more data than intended.', bucket_name, policy_count, case diff --git a/splinter.sql b/splinter.sql index 3a0fe972..29558d29 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1460,7 +1460,11 @@ matching_policies as ( and p.tablename = 'objects' and p.cmd = 'SELECT' where - p.qual ~* (E'bucket_id\\s*=\\s*' || b.quoted_bucket_pattern) + p.qual ~* ( + E'^\\s*\\(*\\s*bucket_id\\s*=\\s*' + || b.quoted_bucket_pattern + || E'(\\s*::\\s*[[:alnum:]_\\.]+)?\\s*\\)*\\s*$' + ) ), affected_buckets as ( select @@ -1480,9 +1484,9 @@ select 'WARN' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects public storage buckets with a SELECT policy on `storage.objects`, which allows clients to list all files in the bucket.' as description, + 'Detects public storage buckets with a bucket-only SELECT policy on `storage.objects`, which allows clients to list all files in the bucket.' as description, format( - 'Public bucket `%s` has %s SELECT %s on `storage.objects` (%s), allowing clients to list all files. Public buckets don''t need this and it may expose more data than intended.', + 'Public bucket `%s` has %s bucket-only SELECT %s on `storage.objects` (%s), allowing clients to list all files. Public buckets don''t need this for object URL access and it may expose more data than intended.', bucket_name, policy_count, case diff --git a/test/expected/0025_public_bucket_allows_listing.out b/test/expected/0025_public_bucket_allows_listing.out index 208379b6..d78f6ae7 100644 --- a/test/expected/0025_public_bucket_allows_listing.out +++ b/test/expected/0025_public_bucket_allows_listing.out @@ -34,8 +34,24 @@ begin; rollback to savepoint a; savepoint b; + -- NEGATIVE EXAMPLE: a public bucket with a constrained SELECT policy should not fire + -- The policy scopes listing to a path prefix rather than broadly listing the whole bucket + insert into storage.buckets(id, name, public) + values ('restricted-list-bucket', 'Restricted list bucket', true); + create policy "restricted_bucket_select" + on storage.objects + for select + to authenticated + using (bucket_id = 'restricted-list-bucket' and name like 'public/%'); + select * from lint."0025_public_bucket_allows_listing"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint b; + savepoint c; -- POSITIVE EXAMPLE: a public bucket with a matching SELECT policy should fire once - -- The SELECT policy references the public bucket directly, so clients can list its contents + -- The bucket-only SELECT policy references the public bucket directly, so clients can list its contents insert into storage.buckets(id, name, public) values ('listable.bucket+1', 'Listable bucket', true); create policy "listable_bucket_select" @@ -63,10 +79,10 @@ begin; ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) - rollback to savepoint b; - savepoint c; + rollback to savepoint c; + savepoint d; -- MULTIPLE POLICIES: the bucket should still produce a single lint with aggregated metadata - -- Both SELECT policies target the same public bucket, so the lint should collapse them into one result + -- Both bucket-only SELECT policies target the same public bucket, so the lint should collapse them into one result insert into storage.buckets(id, name, public) values ('multi-policy-bucket', 'Multi policy bucket', true); create policy "bucket_listing_policy_a" @@ -78,7 +94,7 @@ begin; on storage.objects for select to authenticated - using (bucket_id = 'multi-policy-bucket' and name like 'public/%'); + using ((bucket_id = 'multi-policy-bucket')); select metadata->>'bucket_id' as bucket_id, metadata->>'policy_count' as policy_count, @@ -89,8 +105,8 @@ begin; multi-policy-bucket | 2 | ["bucket_listing_policy_a", "bucket_listing_policy_b"] (1 row) - rollback to savepoint c; - savepoint d; + rollback to savepoint d; + savepoint e; -- PRIVATE BUCKET: matching SELECT policy text alone should not fire -- Private buckets are out of scope for this lint even when the policy text looks similar insert into storage.buckets(id, name, public) @@ -105,8 +121,8 @@ begin; ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) - rollback to savepoint d; - savepoint e; + rollback to savepoint e; + savepoint f; -- MULTIPLE AFFECTED BUCKETS: each affected public bucket should produce its own lint -- Two public buckets each have their own matching SELECT policy, so the lint should emit one row per bucket insert into storage.buckets(id, name, public) @@ -133,5 +149,5 @@ begin; omega-bucket | public_bucket_allows_listing_omega-bucket (2 rows) - rollback to savepoint e; + rollback to savepoint f; rollback; diff --git a/test/sql/0025_public_bucket_allows_listing.sql b/test/sql/0025_public_bucket_allows_listing.sql index e49c6f27..06727c3a 100644 --- a/test/sql/0025_public_bucket_allows_listing.sql +++ b/test/sql/0025_public_bucket_allows_listing.sql @@ -38,8 +38,25 @@ begin; savepoint b; + -- NEGATIVE EXAMPLE: a public bucket with a constrained SELECT policy should not fire + -- The policy scopes listing to a path prefix rather than broadly listing the whole bucket + insert into storage.buckets(id, name, public) + values ('restricted-list-bucket', 'Restricted list bucket', true); + + create policy "restricted_bucket_select" + on storage.objects + for select + to authenticated + using (bucket_id = 'restricted-list-bucket' and name like 'public/%'); + + select * from lint."0025_public_bucket_allows_listing"; + + rollback to savepoint b; + + savepoint c; + -- POSITIVE EXAMPLE: a public bucket with a matching SELECT policy should fire once - -- The SELECT policy references the public bucket directly, so clients can list its contents + -- The bucket-only SELECT policy references the public bucket directly, so clients can list its contents insert into storage.buckets(id, name, public) values ('listable.bucket+1', 'Listable bucket', true); @@ -63,12 +80,12 @@ begin; select * from lint."0025_public_bucket_allows_listing"; - rollback to savepoint b; + rollback to savepoint c; - savepoint c; + savepoint d; -- MULTIPLE POLICIES: the bucket should still produce a single lint with aggregated metadata - -- Both SELECT policies target the same public bucket, so the lint should collapse them into one result + -- Both bucket-only SELECT policies target the same public bucket, so the lint should collapse them into one result insert into storage.buckets(id, name, public) values ('multi-policy-bucket', 'Multi policy bucket', true); @@ -82,7 +99,7 @@ begin; on storage.objects for select to authenticated - using (bucket_id = 'multi-policy-bucket' and name like 'public/%'); + using ((bucket_id = 'multi-policy-bucket')); select metadata->>'bucket_id' as bucket_id, @@ -90,9 +107,9 @@ begin; metadata->'policy_names' as policy_names from lint."0025_public_bucket_allows_listing"; - rollback to savepoint c; + rollback to savepoint d; - savepoint d; + savepoint e; -- PRIVATE BUCKET: matching SELECT policy text alone should not fire -- Private buckets are out of scope for this lint even when the policy text looks similar @@ -107,9 +124,9 @@ begin; select * from lint."0025_public_bucket_allows_listing"; - rollback to savepoint d; + rollback to savepoint e; - savepoint e; + savepoint f; -- MULTIPLE AFFECTED BUCKETS: each affected public bucket should produce its own lint -- Two public buckets each have their own matching SELECT policy, so the lint should emit one row per bucket @@ -135,6 +152,6 @@ begin; cache_key from lint."0025_public_bucket_allows_listing"; - rollback to savepoint e; + rollback to savepoint f; rollback; From bb0bf2b5416778bcf5567be14c75e1a02f2edb1d Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:47:03 +1000 Subject: [PATCH 4/8] cover always true storage listing policies --- docs/0025_public_bucket_allows_listing.md | 6 +-- lints/0025_public_bucket_allows_listing.sql | 17 +++++--- splinter.sql | 17 +++++--- .../0025_public_bucket_allows_listing.out | 42 +++++++++++++++---- .../sql/0025_public_bucket_allows_listing.sql | 42 +++++++++++++++---- 5 files changed, 91 insertions(+), 33 deletions(-) diff --git a/docs/0025_public_bucket_allows_listing.md b/docs/0025_public_bucket_allows_listing.md index ecde59f0..c0bb3583 100644 --- a/docs/0025_public_bucket_allows_listing.md +++ b/docs/0025_public_bucket_allows_listing.md @@ -1,6 +1,6 @@ **Level:** WARN -**Summary:** Detects public storage buckets whose bucket-only `SELECT` policies on `storage.objects` make their contents listable. +**Summary:** Detects public storage buckets whose broad `SELECT` policies on `storage.objects` make their contents listable. **Ramification:** Clients can enumerate the files in a public bucket, which often exposes more information than intended even though public object URLs would still work without the policy. @@ -10,7 +10,7 @@ Supabase public buckets are already readable by URL. They do not need a `SELECT` policy on `storage.objects` for clients to fetch known object paths. -The footgun appears when a public bucket also has one or more bucket-only `SELECT` policies on `storage.objects`, for example `bucket_id = 'avatars'`. That combination allows clients to list objects in the bucket through Storage APIs, which is often broader access than the project intended. +The footgun appears when a public bucket also has one or more broad `SELECT` policies on `storage.objects`, for example `bucket_id = 'avatars'` or `true`. That combination allows clients to list objects in the bucket through Storage APIs, which is often broader access than the project intended. This lint is intentionally narrow. It does not warn on all public buckets. It only warns when a public bucket also has a matching `SELECT` policy that makes its contents enumerable. @@ -59,4 +59,4 @@ drop policy if exists "Public bucket listing" on storage.objects; This lint may fire when broad bucket listing is intentional for a public bucket. In that case, keep the policy and handle the warning as an accepted risk. -The lint is also intentionally conservative. It only detects direct bucket-only `bucket_id = ''` matches. It does not warn on policies that add additional object, path, or user constraints such as `bucket_id = 'avatars' and owner = auth.uid()`. +The lint is also intentionally conservative. It detects direct bucket-only `bucket_id = ''` matches and always-true policy expressions such as `true` or `1 = 1`. It does not warn on policies that add additional object, path, or user constraints such as `bucket_id = 'avatars' and owner = auth.uid()`. diff --git a/lints/0025_public_bucket_allows_listing.sql b/lints/0025_public_bucket_allows_listing.sql index d62bf9b4..93f33794 100644 --- a/lints/0025_public_bucket_allows_listing.sql +++ b/lints/0025_public_bucket_allows_listing.sql @@ -74,10 +74,15 @@ matching_policies as ( and p.tablename = 'objects' and p.cmd = 'SELECT' where - p.qual ~* ( - E'^\\s*\\(*\\s*bucket_id\\s*=\\s*' - || b.quoted_bucket_pattern - || E'(\\s*::\\s*[[:alnum:]_\\.]+)?\\s*\\)*\\s*$' + ( + p.qual is null + or replace(replace(replace(lower(p.qual), ' ', ''), E'\n', ''), E'\t', '') + in ('true', '(true)', '1=1', '(1=1)') + or p.qual ~* ( + E'^\\s*\\(*\\s*bucket_id\\s*=\\s*' + || b.quoted_bucket_pattern + || E'(\\s*::\\s*[[:alnum:]_\\.]+)?\\s*\\)*\\s*$' + ) ) ), affected_buckets as ( @@ -98,9 +103,9 @@ select 'WARN' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects public storage buckets with a bucket-only SELECT policy on `storage.objects`, which allows clients to list all files in the bucket.' as description, + 'Detects public storage buckets with a broad SELECT policy on `storage.objects`, which allows clients to list all files in the bucket.' as description, format( - 'Public bucket `%s` has %s bucket-only SELECT %s on `storage.objects` (%s), allowing clients to list all files. Public buckets don''t need this for object URL access and it may expose more data than intended.', + 'Public bucket `%s` has %s broad SELECT %s on `storage.objects` (%s), allowing clients to list all files. Public buckets don''t need this for object URL access and it may expose more data than intended.', bucket_name, policy_count, case diff --git a/splinter.sql b/splinter.sql index 29558d29..5d4fc2e9 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1460,10 +1460,15 @@ matching_policies as ( and p.tablename = 'objects' and p.cmd = 'SELECT' where - p.qual ~* ( - E'^\\s*\\(*\\s*bucket_id\\s*=\\s*' - || b.quoted_bucket_pattern - || E'(\\s*::\\s*[[:alnum:]_\\.]+)?\\s*\\)*\\s*$' + ( + p.qual is null + or replace(replace(replace(lower(p.qual), ' ', ''), E'\n', ''), E'\t', '') + in ('true', '(true)', '1=1', '(1=1)') + or p.qual ~* ( + E'^\\s*\\(*\\s*bucket_id\\s*=\\s*' + || b.quoted_bucket_pattern + || E'(\\s*::\\s*[[:alnum:]_\\.]+)?\\s*\\)*\\s*$' + ) ) ), affected_buckets as ( @@ -1484,9 +1489,9 @@ select 'WARN' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects public storage buckets with a bucket-only SELECT policy on `storage.objects`, which allows clients to list all files in the bucket.' as description, + 'Detects public storage buckets with a broad SELECT policy on `storage.objects`, which allows clients to list all files in the bucket.' as description, format( - 'Public bucket `%s` has %s bucket-only SELECT %s on `storage.objects` (%s), allowing clients to list all files. Public buckets don''t need this for object URL access and it may expose more data than intended.', + 'Public bucket `%s` has %s broad SELECT %s on `storage.objects` (%s), allowing clients to list all files. Public buckets don''t need this for object URL access and it may expose more data than intended.', bucket_name, policy_count, case diff --git a/test/expected/0025_public_bucket_allows_listing.out b/test/expected/0025_public_bucket_allows_listing.out index d78f6ae7..df2d9e6f 100644 --- a/test/expected/0025_public_bucket_allows_listing.out +++ b/test/expected/0025_public_bucket_allows_listing.out @@ -50,8 +50,32 @@ begin; rollback to savepoint b; savepoint c; + -- POSITIVE EXAMPLE: a public bucket with an always-true SELECT policy should fire once + -- An always-true SELECT policy broadly allows listing, even though it is not bucket-specific + insert into storage.buckets(id, name, public) + values ('always-true-bucket', 'Always true bucket', true); + create policy "always_true_bucket_select" + on storage.objects + for select + to authenticated + using (true); + select + name, + metadata->>'bucket_id' as bucket_id, + metadata->>'bucket_name' as bucket_name, + metadata->>'policy_count' as policy_count, + metadata->'policy_names' as policy_names, + cache_key + from lint."0025_public_bucket_allows_listing"; + name | bucket_id | bucket_name | policy_count | policy_names | cache_key +------------------------------+--------------------+--------------------+--------------+-------------------------------+------------------------------------------------- + public_bucket_allows_listing | always-true-bucket | Always true bucket | 1 | ["always_true_bucket_select"] | public_bucket_allows_listing_always-true-bucket +(1 row) + + rollback to savepoint c; + savepoint d; -- POSITIVE EXAMPLE: a public bucket with a matching SELECT policy should fire once - -- The bucket-only SELECT policy references the public bucket directly, so clients can list its contents + -- The broad SELECT policy references the public bucket directly, so clients can list its contents insert into storage.buckets(id, name, public) values ('listable.bucket+1', 'Listable bucket', true); create policy "listable_bucket_select" @@ -79,10 +103,10 @@ begin; ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) - rollback to savepoint c; - savepoint d; + rollback to savepoint d; + savepoint e; -- MULTIPLE POLICIES: the bucket should still produce a single lint with aggregated metadata - -- Both bucket-only SELECT policies target the same public bucket, so the lint should collapse them into one result + -- Both broad SELECT policies target the same public bucket, so the lint should collapse them into one result insert into storage.buckets(id, name, public) values ('multi-policy-bucket', 'Multi policy bucket', true); create policy "bucket_listing_policy_a" @@ -105,8 +129,8 @@ begin; multi-policy-bucket | 2 | ["bucket_listing_policy_a", "bucket_listing_policy_b"] (1 row) - rollback to savepoint d; - savepoint e; + rollback to savepoint e; + savepoint f; -- PRIVATE BUCKET: matching SELECT policy text alone should not fire -- Private buckets are out of scope for this lint even when the policy text looks similar insert into storage.buckets(id, name, public) @@ -121,8 +145,8 @@ begin; ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) - rollback to savepoint e; - savepoint f; + rollback to savepoint f; + savepoint g; -- MULTIPLE AFFECTED BUCKETS: each affected public bucket should produce its own lint -- Two public buckets each have their own matching SELECT policy, so the lint should emit one row per bucket insert into storage.buckets(id, name, public) @@ -149,5 +173,5 @@ begin; omega-bucket | public_bucket_allows_listing_omega-bucket (2 rows) - rollback to savepoint f; + rollback to savepoint g; rollback; diff --git a/test/sql/0025_public_bucket_allows_listing.sql b/test/sql/0025_public_bucket_allows_listing.sql index 06727c3a..3be399ac 100644 --- a/test/sql/0025_public_bucket_allows_listing.sql +++ b/test/sql/0025_public_bucket_allows_listing.sql @@ -55,8 +55,32 @@ begin; savepoint c; + -- POSITIVE EXAMPLE: a public bucket with an always-true SELECT policy should fire once + -- An always-true SELECT policy broadly allows listing, even though it is not bucket-specific + insert into storage.buckets(id, name, public) + values ('always-true-bucket', 'Always true bucket', true); + + create policy "always_true_bucket_select" + on storage.objects + for select + to authenticated + using (true); + + select + name, + metadata->>'bucket_id' as bucket_id, + metadata->>'bucket_name' as bucket_name, + metadata->>'policy_count' as policy_count, + metadata->'policy_names' as policy_names, + cache_key + from lint."0025_public_bucket_allows_listing"; + + rollback to savepoint c; + + savepoint d; + -- POSITIVE EXAMPLE: a public bucket with a matching SELECT policy should fire once - -- The bucket-only SELECT policy references the public bucket directly, so clients can list its contents + -- The broad SELECT policy references the public bucket directly, so clients can list its contents insert into storage.buckets(id, name, public) values ('listable.bucket+1', 'Listable bucket', true); @@ -80,12 +104,12 @@ begin; select * from lint."0025_public_bucket_allows_listing"; - rollback to savepoint c; + rollback to savepoint d; - savepoint d; + savepoint e; -- MULTIPLE POLICIES: the bucket should still produce a single lint with aggregated metadata - -- Both bucket-only SELECT policies target the same public bucket, so the lint should collapse them into one result + -- Both broad SELECT policies target the same public bucket, so the lint should collapse them into one result insert into storage.buckets(id, name, public) values ('multi-policy-bucket', 'Multi policy bucket', true); @@ -107,9 +131,9 @@ begin; metadata->'policy_names' as policy_names from lint."0025_public_bucket_allows_listing"; - rollback to savepoint d; + rollback to savepoint e; - savepoint e; + savepoint f; -- PRIVATE BUCKET: matching SELECT policy text alone should not fire -- Private buckets are out of scope for this lint even when the policy text looks similar @@ -124,9 +148,9 @@ begin; select * from lint."0025_public_bucket_allows_listing"; - rollback to savepoint e; + rollback to savepoint f; - savepoint f; + savepoint g; -- MULTIPLE AFFECTED BUCKETS: each affected public bucket should produce its own lint -- Two public buckets each have their own matching SELECT policy, so the lint should emit one row per bucket @@ -152,6 +176,6 @@ begin; cache_key from lint."0025_public_bucket_allows_listing"; - rollback to savepoint f; + rollback to savepoint g; rollback; From e548826b8f4f2cf73e82b8750dd4317597516032 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:08:44 +1000 Subject: [PATCH 5/8] align storage listing policy applicability --- docs/0025_public_bucket_allows_listing.md | 4 +- lints/0025_public_bucket_allows_listing.sql | 4 +- splinter.sql | 4 +- .../0025_public_bucket_allows_listing.out | 59 +++++++++++++++--- .../sql/0025_public_bucket_allows_listing.sql | 60 ++++++++++++++++--- 5 files changed, 109 insertions(+), 22 deletions(-) diff --git a/docs/0025_public_bucket_allows_listing.md b/docs/0025_public_bucket_allows_listing.md index c0bb3583..86496170 100644 --- a/docs/0025_public_bucket_allows_listing.md +++ b/docs/0025_public_bucket_allows_listing.md @@ -10,7 +10,7 @@ Supabase public buckets are already readable by URL. They do not need a `SELECT` policy on `storage.objects` for clients to fetch known object paths. -The footgun appears when a public bucket also has one or more broad `SELECT` policies on `storage.objects`, for example `bucket_id = 'avatars'` or `true`. That combination allows clients to list objects in the bucket through Storage APIs, which is often broader access than the project intended. +The footgun appears when a public bucket also has one or more broad permissive `SELECT` or `ALL` policies on `storage.objects`, for example `bucket_id = 'avatars'` or `true`. That combination allows API clients to list objects in the bucket through Storage APIs, which is often broader access than the project intended. This lint is intentionally narrow. It does not warn on all public buckets. It only warns when a public bucket also has a matching `SELECT` policy that makes its contents enumerable. @@ -59,4 +59,4 @@ drop policy if exists "Public bucket listing" on storage.objects; This lint may fire when broad bucket listing is intentional for a public bucket. In that case, keep the policy and handle the warning as an accepted risk. -The lint is also intentionally conservative. It detects direct bucket-only `bucket_id = ''` matches and always-true policy expressions such as `true` or `1 = 1`. It does not warn on policies that add additional object, path, or user constraints such as `bucket_id = 'avatars' and owner = auth.uid()`. +The lint is also intentionally conservative. It detects broad permissive policies for the `public`, `anon`, or `authenticated` roles with direct bucket-only `bucket_id = ''` matches or always-true policy expressions such as `true` or `1 = 1`. It does not warn on restrictive-only policies or policies that add additional object, path, or user constraints such as `bucket_id = 'avatars' and owner = auth.uid()`. diff --git a/lints/0025_public_bucket_allows_listing.sql b/lints/0025_public_bucket_allows_listing.sql index 93f33794..08463cbe 100644 --- a/lints/0025_public_bucket_allows_listing.sql +++ b/lints/0025_public_bucket_allows_listing.sql @@ -72,7 +72,9 @@ matching_policies as ( join pg_catalog.pg_policies p on p.schemaname = 'storage' and p.tablename = 'objects' - and p.cmd = 'SELECT' + and p.cmd in ('SELECT', 'ALL') + and p.permissive = 'PERMISSIVE' + and p.roles && array['public'::name, 'anon'::name, 'authenticated'::name] where ( p.qual is null diff --git a/splinter.sql b/splinter.sql index 5d4fc2e9..d6ddafa0 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1458,7 +1458,9 @@ matching_policies as ( join pg_catalog.pg_policies p on p.schemaname = 'storage' and p.tablename = 'objects' - and p.cmd = 'SELECT' + and p.cmd in ('SELECT', 'ALL') + and p.permissive = 'PERMISSIVE' + and p.roles && array['public'::name, 'anon'::name, 'authenticated'::name] where ( p.qual is null diff --git a/test/expected/0025_public_bucket_allows_listing.out b/test/expected/0025_public_bucket_allows_listing.out index df2d9e6f..12e5c4e1 100644 --- a/test/expected/0025_public_bucket_allows_listing.out +++ b/test/expected/0025_public_bucket_allows_listing.out @@ -50,6 +50,23 @@ begin; rollback to savepoint b; savepoint c; + -- NEGATIVE EXAMPLE: a restrictive-only policy should not fire + -- Restrictive policies do not grant access by themselves + insert into storage.buckets(id, name, public) + values ('restrictive-only-bucket', 'Restrictive only bucket', true); + create policy "restrictive_only_bucket_select" + on storage.objects + as restrictive + for select + to authenticated + using (bucket_id = 'restrictive-only-bucket'); + select * from lint."0025_public_bucket_allows_listing"; + name | title | level | facing | categories | description | detail | remediation | metadata | cache_key +------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- +(0 rows) + + rollback to savepoint c; + savepoint d; -- POSITIVE EXAMPLE: a public bucket with an always-true SELECT policy should fire once -- An always-true SELECT policy broadly allows listing, even though it is not bucket-specific insert into storage.buckets(id, name, public) @@ -72,8 +89,32 @@ begin; public_bucket_allows_listing | always-true-bucket | Always true bucket | 1 | ["always_true_bucket_select"] | public_bucket_allows_listing_always-true-bucket (1 row) - rollback to savepoint c; - savepoint d; + rollback to savepoint d; + savepoint e; + -- POSITIVE EXAMPLE: a public bucket with a broad ALL policy should fire once + -- FOR ALL policies also apply to SELECT, so they can make a public bucket listable + insert into storage.buckets(id, name, public) + values ('all-policy-bucket', 'All policy bucket', true); + create policy "all_policy_bucket_access" + on storage.objects + for all + to authenticated + using (bucket_id = 'all-policy-bucket'); + select + name, + metadata->>'bucket_id' as bucket_id, + metadata->>'bucket_name' as bucket_name, + metadata->>'policy_count' as policy_count, + metadata->'policy_names' as policy_names, + cache_key + from lint."0025_public_bucket_allows_listing"; + name | bucket_id | bucket_name | policy_count | policy_names | cache_key +------------------------------+-------------------+-------------------+--------------+------------------------------+------------------------------------------------ + public_bucket_allows_listing | all-policy-bucket | All policy bucket | 1 | ["all_policy_bucket_access"] | public_bucket_allows_listing_all-policy-bucket +(1 row) + + rollback to savepoint e; + savepoint f; -- POSITIVE EXAMPLE: a public bucket with a matching SELECT policy should fire once -- The broad SELECT policy references the public bucket directly, so clients can list its contents insert into storage.buckets(id, name, public) @@ -103,8 +144,8 @@ begin; ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) - rollback to savepoint d; - savepoint e; + rollback to savepoint f; + savepoint g; -- MULTIPLE POLICIES: the bucket should still produce a single lint with aggregated metadata -- Both broad SELECT policies target the same public bucket, so the lint should collapse them into one result insert into storage.buckets(id, name, public) @@ -129,8 +170,8 @@ begin; multi-policy-bucket | 2 | ["bucket_listing_policy_a", "bucket_listing_policy_b"] (1 row) - rollback to savepoint e; - savepoint f; + rollback to savepoint g; + savepoint h; -- PRIVATE BUCKET: matching SELECT policy text alone should not fire -- Private buckets are out of scope for this lint even when the policy text looks similar insert into storage.buckets(id, name, public) @@ -145,8 +186,8 @@ begin; ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) - rollback to savepoint f; - savepoint g; + rollback to savepoint h; + savepoint i; -- MULTIPLE AFFECTED BUCKETS: each affected public bucket should produce its own lint -- Two public buckets each have their own matching SELECT policy, so the lint should emit one row per bucket insert into storage.buckets(id, name, public) @@ -173,5 +214,5 @@ begin; omega-bucket | public_bucket_allows_listing_omega-bucket (2 rows) - rollback to savepoint g; + rollback to savepoint i; rollback; diff --git a/test/sql/0025_public_bucket_allows_listing.sql b/test/sql/0025_public_bucket_allows_listing.sql index 3be399ac..c71627df 100644 --- a/test/sql/0025_public_bucket_allows_listing.sql +++ b/test/sql/0025_public_bucket_allows_listing.sql @@ -55,6 +55,24 @@ begin; savepoint c; + -- NEGATIVE EXAMPLE: a restrictive-only policy should not fire + -- Restrictive policies do not grant access by themselves + insert into storage.buckets(id, name, public) + values ('restrictive-only-bucket', 'Restrictive only bucket', true); + + create policy "restrictive_only_bucket_select" + on storage.objects + as restrictive + for select + to authenticated + using (bucket_id = 'restrictive-only-bucket'); + + select * from lint."0025_public_bucket_allows_listing"; + + rollback to savepoint c; + + savepoint d; + -- POSITIVE EXAMPLE: a public bucket with an always-true SELECT policy should fire once -- An always-true SELECT policy broadly allows listing, even though it is not bucket-specific insert into storage.buckets(id, name, public) @@ -75,9 +93,33 @@ begin; cache_key from lint."0025_public_bucket_allows_listing"; - rollback to savepoint c; + rollback to savepoint d; - savepoint d; + savepoint e; + + -- POSITIVE EXAMPLE: a public bucket with a broad ALL policy should fire once + -- FOR ALL policies also apply to SELECT, so they can make a public bucket listable + insert into storage.buckets(id, name, public) + values ('all-policy-bucket', 'All policy bucket', true); + + create policy "all_policy_bucket_access" + on storage.objects + for all + to authenticated + using (bucket_id = 'all-policy-bucket'); + + select + name, + metadata->>'bucket_id' as bucket_id, + metadata->>'bucket_name' as bucket_name, + metadata->>'policy_count' as policy_count, + metadata->'policy_names' as policy_names, + cache_key + from lint."0025_public_bucket_allows_listing"; + + rollback to savepoint e; + + savepoint f; -- POSITIVE EXAMPLE: a public bucket with a matching SELECT policy should fire once -- The broad SELECT policy references the public bucket directly, so clients can list its contents @@ -104,9 +146,9 @@ begin; select * from lint."0025_public_bucket_allows_listing"; - rollback to savepoint d; + rollback to savepoint f; - savepoint e; + savepoint g; -- MULTIPLE POLICIES: the bucket should still produce a single lint with aggregated metadata -- Both broad SELECT policies target the same public bucket, so the lint should collapse them into one result @@ -131,9 +173,9 @@ begin; metadata->'policy_names' as policy_names from lint."0025_public_bucket_allows_listing"; - rollback to savepoint e; + rollback to savepoint g; - savepoint f; + savepoint h; -- PRIVATE BUCKET: matching SELECT policy text alone should not fire -- Private buckets are out of scope for this lint even when the policy text looks similar @@ -148,9 +190,9 @@ begin; select * from lint."0025_public_bucket_allows_listing"; - rollback to savepoint f; + rollback to savepoint h; - savepoint g; + savepoint i; -- MULTIPLE AFFECTED BUCKETS: each affected public bucket should produce its own lint -- Two public buckets each have their own matching SELECT policy, so the lint should emit one row per bucket @@ -176,6 +218,6 @@ begin; cache_key from lint."0025_public_bucket_allows_listing"; - rollback to savepoint g; + rollback to savepoint i; rollback; From 0ce29116aab3a4d42906ae77a621e52652a39262 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:25:34 +1000 Subject: [PATCH 6/8] fix storage bucket regex matching --- lints/0025_public_bucket_allows_listing.sql | 42 +++++----------- splinter.sql | 42 +++++----------- .../0025_public_bucket_allows_listing.out | 50 ++++++++++++++----- .../sql/0025_public_bucket_allows_listing.sql | 50 ++++++++++++++----- 4 files changed, 98 insertions(+), 86 deletions(-) diff --git a/lints/0025_public_bucket_allows_listing.sql b/lints/0025_public_bucket_allows_listing.sql index 08463cbe..de747aed 100644 --- a/lints/0025_public_bucket_allows_listing.sql +++ b/lints/0025_public_bucket_allows_listing.sql @@ -16,32 +16,7 @@ public_buckets as ( -- Read storage.buckets at runtime so the lint can load even when storage is not installed. select bucket_id, - bucket_name, - replace( - replace( - replace( - replace( - replace( - replace( - replace(pg_catalog.quote_literal(bucket_id), '.', E'\\.'), - '*', - E'\\*' - ), - '(', - E'\\(' - ), - ')', - E'\\)' - ), - '$', - E'\\$' - ), - '+', - E'\\+' - ), - '?', - E'\\?' - ) as quoted_bucket_pattern + bucket_name from ( select @@ -80,10 +55,17 @@ matching_policies as ( p.qual is null or replace(replace(replace(lower(p.qual), ' ', ''), E'\n', ''), E'\t', '') in ('true', '(true)', '1=1', '(1=1)') - or p.qual ~* ( - E'^\\s*\\(*\\s*bucket_id\\s*=\\s*' - || b.quoted_bucket_pattern - || E'(\\s*::\\s*[[:alnum:]_\\.]+)?\\s*\\)*\\s*$' + or exists ( + select + 1 + from + pg_catalog.regexp_match( + p.qual, + $re$\A\s*\(*\s*bucket_id\s*=\s*('(?:[^']|'')*')(\s*::\s*[[:alnum:]_\.]+)?\s*\)*\s*\Z$re$, + 'i' + ) as bucket_match(matches) + where + bucket_match.matches[1] = '''' || replace(b.bucket_id, '''', '''''') || '''' ) ) ), diff --git a/splinter.sql b/splinter.sql index d6ddafa0..7b8586a4 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1402,32 +1402,7 @@ public_buckets as ( -- Read storage.buckets at runtime so the lint can load even when storage is not installed. select bucket_id, - bucket_name, - replace( - replace( - replace( - replace( - replace( - replace( - replace(pg_catalog.quote_literal(bucket_id), '.', E'\\.'), - '*', - E'\\*' - ), - '(', - E'\\(' - ), - ')', - E'\\)' - ), - '$', - E'\\$' - ), - '+', - E'\\+' - ), - '?', - E'\\?' - ) as quoted_bucket_pattern + bucket_name from ( select @@ -1466,10 +1441,17 @@ matching_policies as ( p.qual is null or replace(replace(replace(lower(p.qual), ' ', ''), E'\n', ''), E'\t', '') in ('true', '(true)', '1=1', '(1=1)') - or p.qual ~* ( - E'^\\s*\\(*\\s*bucket_id\\s*=\\s*' - || b.quoted_bucket_pattern - || E'(\\s*::\\s*[[:alnum:]_\\.]+)?\\s*\\)*\\s*$' + or exists ( + select + 1 + from + pg_catalog.regexp_match( + p.qual, + $re$\A\s*\(*\s*bucket_id\s*=\s*('(?:[^']|'')*')(\s*::\s*[[:alnum:]_\.]+)?\s*\)*\s*\Z$re$, + 'i' + ) as bucket_match(matches) + where + bucket_match.matches[1] = '''' || replace(b.bucket_id, '''', '''''') || '''' ) ) ), diff --git a/test/expected/0025_public_bucket_allows_listing.out b/test/expected/0025_public_bucket_allows_listing.out index 12e5c4e1..1417e830 100644 --- a/test/expected/0025_public_bucket_allows_listing.out +++ b/test/expected/0025_public_bucket_allows_listing.out @@ -50,6 +50,30 @@ begin; rollback to savepoint b; savepoint c; + -- POSITIVE EXAMPLE: regex metacharacters in bucket IDs should be matched literally + -- Bucket IDs are user-controlled names, so regex escaping must handle more than dots and pluses + insert into storage.buckets(id, name, public) + values ('a|b[1]^{x}\z', 'Regex metachar bucket', true); + create policy "regex_metachar_bucket_select" + on storage.objects + for select + to authenticated + using (bucket_id = 'a|b[1]^{x}\z'); + select + name, + metadata->>'bucket_id' as bucket_id, + metadata->>'bucket_name' as bucket_name, + metadata->>'policy_count' as policy_count, + metadata->'policy_names' as policy_names, + cache_key + from lint."0025_public_bucket_allows_listing"; + name | bucket_id | bucket_name | policy_count | policy_names | cache_key +------------------------------+--------------+-----------------------+--------------+----------------------------------+------------------------------------------- + public_bucket_allows_listing | a|b[1]^{x}\z | Regex metachar bucket | 1 | ["regex_metachar_bucket_select"] | public_bucket_allows_listing_a|b[1]^{x}\z +(1 row) + + rollback to savepoint c; + savepoint d; -- NEGATIVE EXAMPLE: a restrictive-only policy should not fire -- Restrictive policies do not grant access by themselves insert into storage.buckets(id, name, public) @@ -65,8 +89,8 @@ begin; ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) - rollback to savepoint c; - savepoint d; + rollback to savepoint d; + savepoint e; -- POSITIVE EXAMPLE: a public bucket with an always-true SELECT policy should fire once -- An always-true SELECT policy broadly allows listing, even though it is not bucket-specific insert into storage.buckets(id, name, public) @@ -89,8 +113,8 @@ begin; public_bucket_allows_listing | always-true-bucket | Always true bucket | 1 | ["always_true_bucket_select"] | public_bucket_allows_listing_always-true-bucket (1 row) - rollback to savepoint d; - savepoint e; + rollback to savepoint e; + savepoint f; -- POSITIVE EXAMPLE: a public bucket with a broad ALL policy should fire once -- FOR ALL policies also apply to SELECT, so they can make a public bucket listable insert into storage.buckets(id, name, public) @@ -113,8 +137,8 @@ begin; public_bucket_allows_listing | all-policy-bucket | All policy bucket | 1 | ["all_policy_bucket_access"] | public_bucket_allows_listing_all-policy-bucket (1 row) - rollback to savepoint e; - savepoint f; + rollback to savepoint f; + savepoint g; -- POSITIVE EXAMPLE: a public bucket with a matching SELECT policy should fire once -- The broad SELECT policy references the public bucket directly, so clients can list its contents insert into storage.buckets(id, name, public) @@ -144,8 +168,8 @@ begin; ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) - rollback to savepoint f; - savepoint g; + rollback to savepoint g; + savepoint h; -- MULTIPLE POLICIES: the bucket should still produce a single lint with aggregated metadata -- Both broad SELECT policies target the same public bucket, so the lint should collapse them into one result insert into storage.buckets(id, name, public) @@ -170,8 +194,8 @@ begin; multi-policy-bucket | 2 | ["bucket_listing_policy_a", "bucket_listing_policy_b"] (1 row) - rollback to savepoint g; - savepoint h; + rollback to savepoint h; + savepoint i; -- PRIVATE BUCKET: matching SELECT policy text alone should not fire -- Private buckets are out of scope for this lint even when the policy text looks similar insert into storage.buckets(id, name, public) @@ -186,8 +210,8 @@ begin; ------+-------+-------+--------+------------+-------------+--------+-------------+----------+----------- (0 rows) - rollback to savepoint h; - savepoint i; + rollback to savepoint i; + savepoint j; -- MULTIPLE AFFECTED BUCKETS: each affected public bucket should produce its own lint -- Two public buckets each have their own matching SELECT policy, so the lint should emit one row per bucket insert into storage.buckets(id, name, public) @@ -214,5 +238,5 @@ begin; omega-bucket | public_bucket_allows_listing_omega-bucket (2 rows) - rollback to savepoint i; + rollback to savepoint j; rollback; diff --git a/test/sql/0025_public_bucket_allows_listing.sql b/test/sql/0025_public_bucket_allows_listing.sql index c71627df..61ace871 100644 --- a/test/sql/0025_public_bucket_allows_listing.sql +++ b/test/sql/0025_public_bucket_allows_listing.sql @@ -55,6 +55,30 @@ begin; savepoint c; + -- POSITIVE EXAMPLE: regex metacharacters in bucket IDs should be matched literally + -- Bucket IDs are user-controlled names, so regex escaping must handle more than dots and pluses + insert into storage.buckets(id, name, public) + values ('a|b[1]^{x}\z', 'Regex metachar bucket', true); + + create policy "regex_metachar_bucket_select" + on storage.objects + for select + to authenticated + using (bucket_id = 'a|b[1]^{x}\z'); + + select + name, + metadata->>'bucket_id' as bucket_id, + metadata->>'bucket_name' as bucket_name, + metadata->>'policy_count' as policy_count, + metadata->'policy_names' as policy_names, + cache_key + from lint."0025_public_bucket_allows_listing"; + + rollback to savepoint c; + + savepoint d; + -- NEGATIVE EXAMPLE: a restrictive-only policy should not fire -- Restrictive policies do not grant access by themselves insert into storage.buckets(id, name, public) @@ -69,9 +93,9 @@ begin; select * from lint."0025_public_bucket_allows_listing"; - rollback to savepoint c; + rollback to savepoint d; - savepoint d; + savepoint e; -- POSITIVE EXAMPLE: a public bucket with an always-true SELECT policy should fire once -- An always-true SELECT policy broadly allows listing, even though it is not bucket-specific @@ -93,9 +117,9 @@ begin; cache_key from lint."0025_public_bucket_allows_listing"; - rollback to savepoint d; + rollback to savepoint e; - savepoint e; + savepoint f; -- POSITIVE EXAMPLE: a public bucket with a broad ALL policy should fire once -- FOR ALL policies also apply to SELECT, so they can make a public bucket listable @@ -117,9 +141,9 @@ begin; cache_key from lint."0025_public_bucket_allows_listing"; - rollback to savepoint e; + rollback to savepoint f; - savepoint f; + savepoint g; -- POSITIVE EXAMPLE: a public bucket with a matching SELECT policy should fire once -- The broad SELECT policy references the public bucket directly, so clients can list its contents @@ -146,9 +170,9 @@ begin; select * from lint."0025_public_bucket_allows_listing"; - rollback to savepoint f; + rollback to savepoint g; - savepoint g; + savepoint h; -- MULTIPLE POLICIES: the bucket should still produce a single lint with aggregated metadata -- Both broad SELECT policies target the same public bucket, so the lint should collapse them into one result @@ -173,9 +197,9 @@ begin; metadata->'policy_names' as policy_names from lint."0025_public_bucket_allows_listing"; - rollback to savepoint g; + rollback to savepoint h; - savepoint h; + savepoint i; -- PRIVATE BUCKET: matching SELECT policy text alone should not fire -- Private buckets are out of scope for this lint even when the policy text looks similar @@ -190,9 +214,9 @@ begin; select * from lint."0025_public_bucket_allows_listing"; - rollback to savepoint h; + rollback to savepoint i; - savepoint i; + savepoint j; -- MULTIPLE AFFECTED BUCKETS: each affected public bucket should produce its own lint -- Two public buckets each have their own matching SELECT policy, so the lint should emit one row per bucket @@ -218,6 +242,6 @@ begin; cache_key from lint."0025_public_bucket_allows_listing"; - rollback to savepoint i; + rollback to savepoint j; rollback; From 99cc1f058d0dfdd7c4b3fee1e993201fc9dc5a72 Mon Sep 17 00:00:00 2001 From: kemal Date: Mon, 13 Apr 2026 19:59:43 +0100 Subject: [PATCH 7/8] feat: add olis cleaner suggestion --- lints/0025_public_bucket_allows_listing.sql | 35 ++++++--------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/lints/0025_public_bucket_allows_listing.sql b/lints/0025_public_bucket_allows_listing.sql index de747aed..656aaba9 100644 --- a/lints/0025_public_bucket_allows_listing.sql +++ b/lints/0025_public_bucket_allows_listing.sql @@ -1,31 +1,13 @@ create view lint."0025_public_bucket_allows_listing" as -with storage_bucket_table as ( - select - 1 - from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - where - n.nspname = 'storage' - and c.relname = 'buckets' - and c.relkind in ('r', 'p') -), -public_buckets as ( +with public_buckets as ( -- Read storage.buckets at runtime so the lint can load even when storage is not installed. select - bucket_id, - bucket_name - from - ( - select - (xpath('/row/id/text()', bucket_xml))[1]::text as bucket_id, - (xpath('/row/name/text()', bucket_xml))[1]::text as bucket_name - from - storage_bucket_table - cross join lateral unnest( - xpath( + (xpath('/row/id/text()', bucket_xml))[1]::text as bucket_id, + (xpath('/row/name/text()', bucket_xml))[1]::text as bucket_name + from unnest( + case when pg_catalog.to_regclass('storage.buckets') is not null + then xpath( '/table/row', pg_catalog.query_to_xml( 'select id, name from storage.buckets where public = true order by id', @@ -34,8 +16,9 @@ public_buckets as ( '' ) ) - ) as bucket_rows(bucket_xml) - ) public_bucket_rows + else array[]::xml[] + end + ) as bucket_xml ), matching_policies as ( select From 096c36f9deedb533eaaae496698237af7ee72b07 Mon Sep 17 00:00:00 2001 From: kemal Date: Mon, 13 Apr 2026 20:28:45 +0100 Subject: [PATCH 8/8] chore: update splinter.sql --- splinter.sql | 35 +++++++++-------------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/splinter.sql b/splinter.sql index 7b8586a4..e48c7ed7 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1386,32 +1386,14 @@ order by policy_name) union all ( -with storage_bucket_table as ( - select - 1 - from - pg_catalog.pg_class c - join pg_catalog.pg_namespace n - on c.relnamespace = n.oid - where - n.nspname = 'storage' - and c.relname = 'buckets' - and c.relkind in ('r', 'p') -), -public_buckets as ( +with public_buckets as ( -- Read storage.buckets at runtime so the lint can load even when storage is not installed. select - bucket_id, - bucket_name - from - ( - select - (xpath('/row/id/text()', bucket_xml))[1]::text as bucket_id, - (xpath('/row/name/text()', bucket_xml))[1]::text as bucket_name - from - storage_bucket_table - cross join lateral unnest( - xpath( + (xpath('/row/id/text()', bucket_xml))[1]::text as bucket_id, + (xpath('/row/name/text()', bucket_xml))[1]::text as bucket_name + from unnest( + case when pg_catalog.to_regclass('storage.buckets') is not null + then xpath( '/table/row', pg_catalog.query_to_xml( 'select id, name from storage.buckets where public = true order by id', @@ -1420,8 +1402,9 @@ public_buckets as ( '' ) ) - ) as bucket_rows(bucket_xml) - ) public_bucket_rows + else array[]::xml[] + end + ) as bucket_xml ), matching_policies as ( select