From 405d1fd75c3db300b90c6224286c5a0125fa9ac3 Mon Sep 17 00:00:00 2001 From: ohad6k Date: Mon, 5 Oct 2026 00:24:26 +0300 Subject: [PATCH] feat(0024): flag SELECT using (true) when the policy name claims owner or role scope Keeps the SELECT exemption from #141, except for permissive SELECT policies for anon, authenticated or public whose name claims per-user, admin or service role scope. Adds a detail sentence when a scope-claiming name sits on a policy where every expression is always true. Co-Authored-By: Claude Opus 5.5 --- docs/0024_permissive_rls_policy.md | 48 ++- lints/0024_rls_policy_always_true.sql | 58 +++- splinter.sql | 58 +++- test/expected/0024_rls_policy_always_true.out | 298 ++++++++++++++++++ test/sql/0024_rls_policy_always_true.sql | 253 +++++++++++++++ 5 files changed, 693 insertions(+), 22 deletions(-) diff --git a/docs/0024_permissive_rls_policy.md b/docs/0024_permissive_rls_policy.md index 14225381..7204a83a 100644 --- a/docs/0024_permissive_rls_policy.md +++ b/docs/0024_permissive_rls_policy.md @@ -23,8 +23,8 @@ The lint identifies policies with these always-true patterns: **USING Clause (controls which rows can be read):** - `USING (true)` - explicitly allows reading all rows - `USING (1=1)` - tautology that always evaluates to true -- `USING ('a'='a')` - string comparison tautology -- Missing USING clause on permissive SELECT policies + +`SELECT` policies with `USING (true)` are not flagged, because public read access is often intentional. The exception is a `SELECT` policy whose name says it is limited to the row owner or a role (see below). **WITH CHECK Clause (controls which rows can be written):** - `WITH CHECK (true)` - allows writing any row @@ -41,6 +41,44 @@ When a permissive policy with `USING (true)` exists: This is particularly dangerous when the policy applies to `anon` or `authenticated` roles, as it exposes data to all API users. +### Policy Names That Claim Ownership + +A policy name is what people read when they review RLS. Postgres only enforces the expression. A policy like this looks correct in a list of policies, but returns every row to every signed in user: + +```sql +create policy "Users can view their own posts" +on public.posts +for select +to authenticated +using (true); +``` + +A policy name counts as claiming scope when it contains one of these (matched as whole words, case-insensitive, with `_`, `-` and other punctuation treated as spaces): + +- `own` or `their`, e.g. "Users can view their own posts", "select_own_posts" +- `only owner`, `only owners`, `only the owner`, `only the owners` +- `for owner`, `for owners`, `for the owner`, `for the owners` +- `owner can`, `owners can`, `owner only`, `owners only` +- `admin` or `admins` followed by `can`, `only`, `read`, `reads`, `view`, `views`, `see`, `sees`, `manage`, `manages`, `select`, `insert`, `update` or `delete`, e.g. "Admin reads all activity", "admin_select_responses" +- `only admin`, `only admins` +- `for admin` or `for admins` at the very end of the name, e.g. "Allow authenticated read for admin" (but not "... for admin reports") +- `service role` + +A bare `owner` or `admin` is not enough, because it often names the table's contents ("Allow reading admin users", "Public can view vehicle owners"). A name that contains `public`, `everyone` or `anyone` never counts as claiming scope. + +These are English-name heuristics, so some names that do claim scope are not matched: + +- a name that also says `public`, `everyone` or `anyone`, e.g. "Anyone can read own payments by phone", or one where `public` is part of a table name, e.g. "Users can view own public_profiles" +- camelCase names, e.g. "usersViewOwnPosts" +- names in other languages + +For a policy name that claims scope, the lint: + +- flags a `SELECT` policy with an always-true `USING` clause, which it would otherwise skip +- adds a note to the detail message of any flagged policy where nothing in the policy checks the caller + +A policy for the `service_role` is not needed at all, because the `service_role` bypasses RLS. A policy named after it applies to the roles in its `to` clause, and to every role when `to` is left out. + ### How to Resolve **Option 1: Add proper row-level conditions** @@ -132,3 +170,9 @@ In some cases, `USING (true)` may be intentional: - Tables where access is controlled by other means (e.g., API layer) If the policy is intentional, you can document why in a comment or consider suppressing this lint for specific tables. + +If a `SELECT` policy is flagged only because of its name and the table really is public, rename the policy so the name matches what it does: + +```sql +alter policy "Users can view their own posts" on public.posts rename to "Posts are viewable by everyone"; +``` diff --git a/lints/0024_rls_policy_always_true.sql b/lints/0024_rls_policy_always_true.sql index 7e05efaf..66375f4a 100644 --- a/lints/0024_rls_policy_always_true.sql +++ b/lints/0024_rls_policy_always_true.sql @@ -20,9 +20,28 @@ with policies as ( end as command, pb.qual, pb.with_check, - -- Normalize expressions by removing whitespace and lowercasing - replace(replace(replace(lower(coalesce(pb.qual, '')), ' ', ''), E'\n', ''), E'\t', '') as normalized_qual, - replace(replace(replace(lower(coalesce(pb.with_check, '')), ' ', ''), E'\n', ''), E'\t', '') as normalized_with_check + -- Normalize expressions by removing whitespace and lowercasing, then check for always-true forms + replace(replace(replace(lower(coalesce(pb.qual, '')), ' ', ''), E'\n', ''), E'\t', '') + in ('true', '(true)', '1=1', '(1=1)') as qual_always_true, + replace(replace(replace(lower(coalesce(pb.with_check, '')), ' ', ''), E'\n', ''), E'\t', '') + in ('true', '(true)', '1=1', '(1=1)') as with_check_always_true, + -- Policy name claims the policy is scoped to the row owner or a role. Bare words are not enough: + -- in "Allow reading admin users" or "Public can view vehicle owners" the word describes the + -- table's contents. Names that declare public, everyone or anyone access never count. + ( + policy_name_words.words ~ any(array[ + ' (own|their) ', -- "Users can view their own posts", "select_own_posts" + ' only (the )?owners? ', -- "Only owners can read", "Readable by only the owner" + ' for (the )?owners? ', -- "Read access for the owner" + ' owners? (can|only) ', -- "Owners can view posts", "Owner only read" + ' admins? (can|only|read|reads|view|views|see|sees|manage|manages|select|insert|update|delete) ', + -- "Admins can view orders", "Admin reads all activity", "admin_select_responses" + ' only admins? ', -- "Visible to only admins" + ' for admins? $', -- "Allow authenticated read for admin", but not "... for admin reports" + ' service role ' -- "Service role can read logs" + ]) + and policy_name_words.words !~ ' (public|everyone|anyone) ' + ) as name_implies_scope from pg_catalog.pg_policy pa join pg_catalog.pg_class pc @@ -33,6 +52,11 @@ with policies as ( on pc.relname = pb.tablename and nsp.nspname = pb.schemaname and pa.polname = pb.policyname + -- Lowercased policy name with every non-alphanumeric run turned into one space and a space at each end, + -- so the patterns above match whole words: "select_own_posts" matches " own ", "shown" does not + cross join lateral ( + select ' ' || regexp_replace(lower(pa.polname), '[^a-z0-9]+', ' ', 'g') || ' ' as words + ) as policy_name_words where pc.relkind = 'r' -- regular tables and nsp.nspname not in ( @@ -43,23 +67,28 @@ permissive_patterns as ( select p.*, -- Check for always-true USING clause patterns - -- Note: SELECT with (true) is often intentional and documented, so we only flag UPDATE/DELETE + -- Note: SELECT with (true) is often intentional and documented, so we only flag UPDATE/DELETE, + -- and SELECT only when the policy name says it is scoped to the row owner or a role case when ( command in ('UPDATE', 'DELETE', 'ALL') and ( - normalized_qual in ('true', '(true)', '1=1', '(1=1)') + qual_always_true -- Empty or null qual on permissive policy means allow all or (qual is null and is_permissive) ) + ) or ( + command = 'SELECT' + and name_implies_scope + and qual_always_true ) then true else false end as has_permissive_using, -- Check for always-true WITH CHECK clause patterns case when ( - normalized_with_check in ('true', '(true)', '1=1', '(1=1)') + with_check_always_true -- Empty with_check on INSERT means allow all (INSERT has no USING to fall back on) or (with_check is null and is_permissive and command = 'INSERT') -- Empty with_check on UPDATE/ALL with permissive USING means allow all writes or (with_check is null and is_permissive and command in ('UPDATE', 'ALL') - and normalized_qual in ('true', '(true)', '1=1', '(1=1)')) + and qual_always_true) ) then true else false end as has_permissive_with_check from policies p @@ -84,9 +113,9 @@ select 'WARN' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects RLS policies that use overly permissive expressions like \`USING (true)\` or \`WITH CHECK (true)\` for UPDATE, DELETE, or INSERT operations. SELECT policies with \`USING (true)\` are intentionally excluded as this pattern is often used deliberately for public read access.' as description, + 'Detects RLS policies that use overly permissive expressions like \`USING (true)\` or \`WITH CHECK (true)\` for UPDATE, DELETE, or INSERT operations. SELECT policies with \`USING (true)\` are excluded as this pattern is often used deliberately for public read access, unless the policy name says it is limited to the row owner or a role.' as description, format( - 'Table `%s.%s` has an RLS policy `%s` for `%s` that allows unrestricted access%s. This effectively bypasses row-level security for %s.', + 'Table `%s.%s` has an RLS policy `%s` for `%s` that allows unrestricted access%s. This effectively bypasses row-level security for %s.%s', schema_name, table_name, policy_name, @@ -97,7 +126,16 @@ select when has_permissive_with_check then ' (WITH CHECK clause is always true)' else '' end, - array_to_string(roles, ', ') + array_to_string(roles, ', '), + -- Only when nothing in the policy checks the caller: every expression it has is always true. + -- A missing USING only counts for INSERT, which has none. + case + when name_implies_scope + and (qual_always_true or (qual is null and command = 'INSERT')) + and (with_check is null or with_check_always_true) + then ' The policy name suggests access is limited to the row owner or a specific role, but the policy does not check who the caller is.' + else '' + end ) as detail, 'https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy' as remediation, jsonb_build_object( diff --git a/splinter.sql b/splinter.sql index 46bb331e..9a17a6bb 100644 --- a/splinter.sql +++ b/splinter.sql @@ -1314,9 +1314,28 @@ with policies as ( end as command, pb.qual, pb.with_check, - -- Normalize expressions by removing whitespace and lowercasing - replace(replace(replace(lower(coalesce(pb.qual, '')), ' ', ''), E'\n', ''), E'\t', '') as normalized_qual, - replace(replace(replace(lower(coalesce(pb.with_check, '')), ' ', ''), E'\n', ''), E'\t', '') as normalized_with_check + -- Normalize expressions by removing whitespace and lowercasing, then check for always-true forms + replace(replace(replace(lower(coalesce(pb.qual, '')), ' ', ''), E'\n', ''), E'\t', '') + in ('true', '(true)', '1=1', '(1=1)') as qual_always_true, + replace(replace(replace(lower(coalesce(pb.with_check, '')), ' ', ''), E'\n', ''), E'\t', '') + in ('true', '(true)', '1=1', '(1=1)') as with_check_always_true, + -- Policy name claims the policy is scoped to the row owner or a role. Bare words are not enough: + -- in "Allow reading admin users" or "Public can view vehicle owners" the word describes the + -- table's contents. Names that declare public, everyone or anyone access never count. + ( + policy_name_words.words ~ any(array[ + ' (own|their) ', -- "Users can view their own posts", "select_own_posts" + ' only (the )?owners? ', -- "Only owners can read", "Readable by only the owner" + ' for (the )?owners? ', -- "Read access for the owner" + ' owners? (can|only) ', -- "Owners can view posts", "Owner only read" + ' admins? (can|only|read|reads|view|views|see|sees|manage|manages|select|insert|update|delete) ', + -- "Admins can view orders", "Admin reads all activity", "admin_select_responses" + ' only admins? ', -- "Visible to only admins" + ' for admins? $', -- "Allow authenticated read for admin", but not "... for admin reports" + ' service role ' -- "Service role can read logs" + ]) + and policy_name_words.words !~ ' (public|everyone|anyone) ' + ) as name_implies_scope from pg_catalog.pg_policy pa join pg_catalog.pg_class pc @@ -1327,6 +1346,11 @@ with policies as ( on pc.relname = pb.tablename and nsp.nspname = pb.schemaname and pa.polname = pb.policyname + -- Lowercased policy name with every non-alphanumeric run turned into one space and a space at each end, + -- so the patterns above match whole words: "select_own_posts" matches " own ", "shown" does not + cross join lateral ( + select ' ' || regexp_replace(lower(pa.polname), '[^a-z0-9]+', ' ', 'g') || ' ' as words + ) as policy_name_words where pc.relkind = 'r' -- regular tables and nsp.nspname not in ( @@ -1337,23 +1361,28 @@ permissive_patterns as ( select p.*, -- Check for always-true USING clause patterns - -- Note: SELECT with (true) is often intentional and documented, so we only flag UPDATE/DELETE + -- Note: SELECT with (true) is often intentional and documented, so we only flag UPDATE/DELETE, + -- and SELECT only when the policy name says it is scoped to the row owner or a role case when ( command in ('UPDATE', 'DELETE', 'ALL') and ( - normalized_qual in ('true', '(true)', '1=1', '(1=1)') + qual_always_true -- Empty or null qual on permissive policy means allow all or (qual is null and is_permissive) ) + ) or ( + command = 'SELECT' + and name_implies_scope + and qual_always_true ) then true else false end as has_permissive_using, -- Check for always-true WITH CHECK clause patterns case when ( - normalized_with_check in ('true', '(true)', '1=1', '(1=1)') + with_check_always_true -- Empty with_check on INSERT means allow all (INSERT has no USING to fall back on) or (with_check is null and is_permissive and command = 'INSERT') -- Empty with_check on UPDATE/ALL with permissive USING means allow all writes or (with_check is null and is_permissive and command in ('UPDATE', 'ALL') - and normalized_qual in ('true', '(true)', '1=1', '(1=1)')) + and qual_always_true) ) then true else false end as has_permissive_with_check from policies p @@ -1378,9 +1407,9 @@ select 'WARN' as level, 'EXTERNAL' as facing, array['SECURITY'] as categories, - 'Detects RLS policies that use overly permissive expressions like \`USING (true)\` or \`WITH CHECK (true)\` for UPDATE, DELETE, or INSERT operations. SELECT policies with \`USING (true)\` are intentionally excluded as this pattern is often used deliberately for public read access.' as description, + 'Detects RLS policies that use overly permissive expressions like \`USING (true)\` or \`WITH CHECK (true)\` for UPDATE, DELETE, or INSERT operations. SELECT policies with \`USING (true)\` are excluded as this pattern is often used deliberately for public read access, unless the policy name says it is limited to the row owner or a role.' as description, format( - 'Table `%s.%s` has an RLS policy `%s` for `%s` that allows unrestricted access%s. This effectively bypasses row-level security for %s.', + 'Table `%s.%s` has an RLS policy `%s` for `%s` that allows unrestricted access%s. This effectively bypasses row-level security for %s.%s', schema_name, table_name, policy_name, @@ -1391,7 +1420,16 @@ select when has_permissive_with_check then ' (WITH CHECK clause is always true)' else '' end, - array_to_string(roles, ', ') + array_to_string(roles, ', '), + -- Only when nothing in the policy checks the caller: every expression it has is always true. + -- A missing USING only counts for INSERT, which has none. + case + when name_implies_scope + and (qual_always_true or (qual is null and command = 'INSERT')) + and (with_check is null or with_check_always_true) + then ' The policy name suggests access is limited to the row owner or a specific role, but the policy does not check who the caller is.' + else '' + end ) as detail, 'https://supabase.com/docs/guides/database/database-linter?lint=0024_permissive_rls_policy' as remediation, jsonb_build_object( diff --git a/test/expected/0024_rls_policy_always_true.out b/test/expected/0024_rls_policy_always_true.out index 35fc690d..df7d3566 100644 --- a/test/expected/0024_rls_policy_always_true.out +++ b/test/expected/0024_rls_policy_always_true.out @@ -308,4 +308,302 @@ begin; (1 row) drop policy "insert_no_with_check" on public.posts; + ---------------------------------------- + -- Test: SELECT with USING (true) whose name claims ownership SHOULD be flagged + -- The name says the policy is per-user, so the always-true expression is not intentional public read access + ---------------------------------------- + create policy "Users can view their own posts" + on public.posts + for select + to authenticated + using (true); + select metadata->>'policy_name' as policy_name, metadata->>'command' as command, detail, cache_key from lint."0024_rls_policy_always_true"; + policy_name | command | detail | cache_key +--------------------------------+---------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------- + Users can view their own posts | SELECT | Table `public.posts` has an RLS policy `Users can view their own posts` for `SELECT` that allows unrestricted access (USING clause is always true). This effectively bypasses row-level security for authenticated. The policy name suggests access is limited to the row owner or a specific role, but the policy does not check who the caller is. | rls_policy_always_true_public_posts_Users can view their own posts +(1 row) + + drop policy "Users can view their own posts" on public.posts; + ---------------------------------------- + -- Test: name matching treats underscores and hyphens as word separators + ---------------------------------------- + create policy "select_own_posts" + on public.posts + for select + to authenticated + using (true); + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + policy_name +------------------ + select_own_posts +(1 row) + + drop policy "select_own_posts" on public.posts; + ---------------------------------------- + -- Test: SELECT with USING (true) whose name claims a role SHOULD be flagged + ---------------------------------------- + create policy "Admins can view all posts" + on public.posts + for select + to authenticated + using (true); + create policy "Service role can read posts" + on public.posts + for select + using (true); + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + policy_name | command +-----------------------------+--------- + Admins can view all posts | SELECT + Service role can read posts | SELECT +(2 rows) + + drop policy "Admins can view all posts" on public.posts; + drop policy "Service role can read posts" on public.posts; + ---------------------------------------- + -- Test: SELECT with USING (true) and a public-sounding or neutral name should NOT be flagged + -- "own" inside another word (e.g. "shown") must not match + ---------------------------------------- + create policy "Public profiles are viewable by everyone." + on public.posts + for select + using (true); + create policy "Posts shown on the homepage" + on public.posts + for select + to anon + using (true); + create policy "Public read only" + on public.posts + for select + to anon, authenticated + using (true); + select count(*) from lint."0024_rls_policy_always_true"; + count +------- + 0 +(1 row) + + drop policy "Public profiles are viewable by everyone." on public.posts; + drop policy "Posts shown on the homepage" on public.posts; + drop policy "Public read only" on public.posts; + ---------------------------------------- + -- Test: real SELECT USING (true) policy names that claim per-user or admin scope SHOULD be flagged + ---------------------------------------- + create policy "Admin reads all activity" on public.posts for select to authenticated using (true); + create policy "Users can view their restaurant's marketing data" on public.posts for select to authenticated using (true); + create policy "Users can view their own import jobs" on public.posts for select to authenticated using (true); + create policy "Allow authenticated read for admin" on public.posts for select to authenticated using (true); + create policy "admin_select_responses" on public.posts for select to authenticated using (true); + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + policy_name +-------------------------------------------------- + Admin reads all activity + Allow authenticated read for admin + Users can view their own import jobs + Users can view their restaurant's marketing data + admin_select_responses +(5 rows) + + drop policy "Admin reads all activity" on public.posts; + drop policy "Users can view their restaurant's marketing data" on public.posts; + drop policy "Users can view their own import jobs" on public.posts; + drop policy "Allow authenticated read for admin" on public.posts; + drop policy "admin_select_responses" on public.posts; + ---------------------------------------- + -- Test: real SELECT USING (true) policy names should NOT be flagged when the owner or admin word + -- describes the table's contents, or when the name declares public or anyone access + ---------------------------------------- + create policy "Allow reading admin users" on public.posts for select to authenticated using (true); + create policy "Allow public read access for admins" on public.posts for select using (true); + create policy "Allow public read access for admin activity logs" on public.posts for select using (true); + create policy "Public can view vehicle owners" on public.posts for select using (true); + create policy "Anyone can read own payments by phone" on public.posts for select using (true); + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + policy_name +------------- +(0 rows) + + drop policy "Allow reading admin users" on public.posts; + drop policy "Allow public read access for admins" on public.posts; + drop policy "Allow public read access for admin activity logs" on public.posts; + drop policy "Public can view vehicle owners" on public.posts; + drop policy "Anyone can read own payments by phone" on public.posts; + ---------------------------------------- + -- Test: each owner and admin phrase SHOULD be flagged on its own, an "all users" name that + -- claims ownership SHOULD be flagged, and USING (1=1) counts as always true for SELECT + ---------------------------------------- + create policy "Owners can view posts" on public.posts for select to authenticated using (true); + create policy "Only owners can read" on public.posts for select to authenticated using (true); + create policy "Readable by only the owner" on public.posts for select to authenticated using (true); + create policy "Read access for the owner" on public.posts for select to authenticated using (true); + create policy "Owner only read" on public.posts for select to authenticated using (true); + create policy "Visible to only admins" on public.posts for select to authenticated using (true); + create policy "All users can view their own posts" on public.posts for select to authenticated using (true); + create policy "Users can view own posts" on public.posts for select to authenticated using (1=1); + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + policy_name +------------------------------------ + All users can view their own posts + Only owners can read + Owner only read + Owners can view posts + Read access for the owner + Readable by only the owner + Users can view own posts + Visible to only admins +(8 rows) + + drop policy "Owners can view posts" on public.posts; + drop policy "Only owners can read" on public.posts; + drop policy "Readable by only the owner" on public.posts; + drop policy "Read access for the owner" on public.posts; + drop policy "Owner only read" on public.posts; + drop policy "Visible to only admins" on public.posts; + drop policy "All users can view their own posts" on public.posts; + drop policy "Users can view own posts" on public.posts; + ---------------------------------------- + -- Test: "for admin" only counts at the end of the name, and "everyone" blocks a scope word + ---------------------------------------- + create policy "Authenticated can read for admin reports" on public.posts for select to authenticated using (true); + create policy "Everyone can view their posts" on public.posts for select using (true); + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + policy_name +------------- +(0 rows) + + drop policy "Authenticated can read for admin reports" on public.posts; + drop policy "Everyone can view their posts" on public.posts; + ---------------------------------------- + -- Test: each word after "admin" SHOULD be flagged on its own + -- ("can", "reads" and "select" are covered above) + ---------------------------------------- + create policy "Admin only" on public.posts for select to authenticated using (true); + create policy "Admin read posts" on public.posts for select to authenticated using (true); + create policy "Admin view" on public.posts for select to authenticated using (true); + create policy "Admin views posts" on public.posts for select to authenticated using (true); + create policy "Admins see posts" on public.posts for select to authenticated using (true); + create policy "Admin sees posts" on public.posts for select to authenticated using (true); + create policy "Admins manage posts" on public.posts for select to authenticated using (true); + create policy "Admin manages posts" on public.posts for select to authenticated using (true); + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + policy_name +--------------------- + Admin manages posts + Admin only + Admin read posts + Admin sees posts + Admin view + Admin views posts + Admins manage posts + Admins see posts +(8 rows) + + drop policy "Admin only" on public.posts; + drop policy "Admin read posts" on public.posts; + drop policy "Admin view" on public.posts; + drop policy "Admin views posts" on public.posts; + drop policy "Admins see posts" on public.posts; + drop policy "Admin sees posts" on public.posts; + drop policy "Admins manage posts" on public.posts; + drop policy "Admin manages posts" on public.posts; + ---------------------------------------- + -- Test: write policies named "admin insert/update/delete" get the name note in detail, + -- and a write policy with a neutral name does not + ---------------------------------------- + create policy "admin_insert_posts" on public.posts for insert to authenticated with check (true); + create policy "admin_update_posts" on public.posts for update to authenticated using (true); + create policy "admin_delete_posts" on public.posts for delete to authenticated using (true); + create policy "update_any_post" on public.posts for update to authenticated using (true); + select metadata->>'policy_name' as policy_name, detail from lint."0024_rls_policy_always_true"; + policy_name | detail +--------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + admin_delete_posts | Table `public.posts` has an RLS policy `admin_delete_posts` for `DELETE` that allows unrestricted access (USING clause is always true). This effectively bypasses row-level security for authenticated. The policy name suggests access is limited to the row owner or a specific role, but the policy does not check who the caller is. + admin_insert_posts | Table `public.posts` has an RLS policy `admin_insert_posts` for `INSERT` that allows unrestricted access (WITH CHECK clause is always true). This effectively bypasses row-level security for authenticated. The policy name suggests access is limited to the row owner or a specific role, but the policy does not check who the caller is. + admin_update_posts | Table `public.posts` has an RLS policy `admin_update_posts` for `UPDATE` that allows unrestricted access (both USING and WITH CHECK are always true). This effectively bypasses row-level security for authenticated. The policy name suggests access is limited to the row owner or a specific role, but the policy does not check who the caller is. + update_any_post | Table `public.posts` has an RLS policy `update_any_post` for `UPDATE` that allows unrestricted access (both USING and WITH CHECK are always true). This effectively bypasses row-level security for authenticated. +(4 rows) + + drop policy "admin_insert_posts" on public.posts; + drop policy "admin_update_posts" on public.posts; + drop policy "admin_delete_posts" on public.posts; + drop policy "update_any_post" on public.posts; + ---------------------------------------- + -- Test: SELECT whose name claims ownership and whose USING checks the owner should NOT be flagged + ---------------------------------------- + create policy "Users can view their own posts" + on public.posts + for select + to authenticated + using (user_id = auth.uid()); + select count(*) from lint."0024_rls_policy_always_true"; + count +------- + 0 +(1 row) + + drop policy "Users can view their own posts" on public.posts; + ---------------------------------------- + -- Test: write policies whose name claims ownership get the name note in detail + -- when nothing in the policy checks the caller + ---------------------------------------- + create policy "Users can update their own posts" + on public.posts + for update + to authenticated + using (true); + create policy "Users can insert their own posts" + on public.posts + for insert + to authenticated + with check (true); + select metadata->>'policy_name' as policy_name, detail from lint."0024_rls_policy_always_true"; + policy_name | detail +----------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + Users can insert their own posts | Table `public.posts` has an RLS policy `Users can insert their own posts` for `INSERT` that allows unrestricted access (WITH CHECK clause is always true). This effectively bypasses row-level security for authenticated. The policy name suggests access is limited to the row owner or a specific role, but the policy does not check who the caller is. + Users can update their own posts | Table `public.posts` has an RLS policy `Users can update their own posts` for `UPDATE` that allows unrestricted access (both USING and WITH CHECK are always true). This effectively bypasses row-level security for authenticated. The policy name suggests access is limited to the row owner or a specific role, but the policy does not check who the caller is. +(2 rows) + + drop policy "Users can update their own posts" on public.posts; + drop policy "Users can insert their own posts" on public.posts; + ---------------------------------------- + -- Test: UPDATE policies where one clause is always true and the other checks the owner + -- are still flagged, without the name note, because part of the policy does check the caller + ---------------------------------------- + create policy "Users can update their own posts" + on public.posts + for update + to authenticated + using (user_id = auth.uid()) + with check (true); + create policy "Owners can update posts" + on public.posts + for update + to authenticated + using (true) + with check (user_id = auth.uid()); + select metadata->>'policy_name' as policy_name, detail from lint."0024_rls_policy_always_true"; + policy_name | detail +----------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + Owners can update posts | Table `public.posts` has an RLS policy `Owners can update posts` for `UPDATE` that allows unrestricted access (USING clause is always true). This effectively bypasses row-level security for authenticated. + Users can update their own posts | Table `public.posts` has an RLS policy `Users can update their own posts` for `UPDATE` that allows unrestricted access (WITH CHECK clause is always true). This effectively bypasses row-level security for authenticated. +(2 rows) + + drop policy "Users can update their own posts" on public.posts; + drop policy "Owners can update posts" on public.posts; + ---------------------------------------- + -- Test: a DELETE policy with no USING is still flagged, but without the name note, + -- since a missing USING only counts as "checks nobody" for INSERT + ---------------------------------------- + create policy "Users can delete their own posts" + on public.posts + for delete + to authenticated; + select metadata->>'policy_name' as policy_name, detail from lint."0024_rls_policy_always_true"; + policy_name | detail +----------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + Users can delete their own posts | Table `public.posts` has an RLS policy `Users can delete their own posts` for `DELETE` that allows unrestricted access (USING clause is always true). This effectively bypasses row-level security for authenticated. +(1 row) + + drop policy "Users can delete their own posts" on public.posts; rollback; diff --git a/test/sql/0024_rls_policy_always_true.sql b/test/sql/0024_rls_policy_always_true.sql index f175e4c8..b61f4ca5 100644 --- a/test/sql/0024_rls_policy_always_true.sql +++ b/test/sql/0024_rls_policy_always_true.sql @@ -271,4 +271,257 @@ begin; drop policy "insert_no_with_check" on public.posts; + ---------------------------------------- + -- Test: SELECT with USING (true) whose name claims ownership SHOULD be flagged + -- The name says the policy is per-user, so the always-true expression is not intentional public read access + ---------------------------------------- + create policy "Users can view their own posts" + on public.posts + for select + to authenticated + using (true); + + select metadata->>'policy_name' as policy_name, metadata->>'command' as command, detail, cache_key from lint."0024_rls_policy_always_true"; + + drop policy "Users can view their own posts" on public.posts; + + ---------------------------------------- + -- Test: name matching treats underscores and hyphens as word separators + ---------------------------------------- + create policy "select_own_posts" + on public.posts + for select + to authenticated + using (true); + + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + + drop policy "select_own_posts" on public.posts; + + ---------------------------------------- + -- Test: SELECT with USING (true) whose name claims a role SHOULD be flagged + ---------------------------------------- + create policy "Admins can view all posts" + on public.posts + for select + to authenticated + using (true); + + create policy "Service role can read posts" + on public.posts + for select + using (true); + + select metadata->>'policy_name' as policy_name, metadata->>'command' as command from lint."0024_rls_policy_always_true"; + + drop policy "Admins can view all posts" on public.posts; + drop policy "Service role can read posts" on public.posts; + + ---------------------------------------- + -- Test: SELECT with USING (true) and a public-sounding or neutral name should NOT be flagged + -- "own" inside another word (e.g. "shown") must not match + ---------------------------------------- + create policy "Public profiles are viewable by everyone." + on public.posts + for select + using (true); + + create policy "Posts shown on the homepage" + on public.posts + for select + to anon + using (true); + + create policy "Public read only" + on public.posts + for select + to anon, authenticated + using (true); + + select count(*) from lint."0024_rls_policy_always_true"; + + drop policy "Public profiles are viewable by everyone." on public.posts; + drop policy "Posts shown on the homepage" on public.posts; + drop policy "Public read only" on public.posts; + + ---------------------------------------- + -- Test: real SELECT USING (true) policy names that claim per-user or admin scope SHOULD be flagged + ---------------------------------------- + create policy "Admin reads all activity" on public.posts for select to authenticated using (true); + create policy "Users can view their restaurant's marketing data" on public.posts for select to authenticated using (true); + create policy "Users can view their own import jobs" on public.posts for select to authenticated using (true); + create policy "Allow authenticated read for admin" on public.posts for select to authenticated using (true); + create policy "admin_select_responses" on public.posts for select to authenticated using (true); + + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + + drop policy "Admin reads all activity" on public.posts; + drop policy "Users can view their restaurant's marketing data" on public.posts; + drop policy "Users can view their own import jobs" on public.posts; + drop policy "Allow authenticated read for admin" on public.posts; + drop policy "admin_select_responses" on public.posts; + + ---------------------------------------- + -- Test: real SELECT USING (true) policy names should NOT be flagged when the owner or admin word + -- describes the table's contents, or when the name declares public or anyone access + ---------------------------------------- + create policy "Allow reading admin users" on public.posts for select to authenticated using (true); + create policy "Allow public read access for admins" on public.posts for select using (true); + create policy "Allow public read access for admin activity logs" on public.posts for select using (true); + create policy "Public can view vehicle owners" on public.posts for select using (true); + create policy "Anyone can read own payments by phone" on public.posts for select using (true); + + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + + drop policy "Allow reading admin users" on public.posts; + drop policy "Allow public read access for admins" on public.posts; + drop policy "Allow public read access for admin activity logs" on public.posts; + drop policy "Public can view vehicle owners" on public.posts; + drop policy "Anyone can read own payments by phone" on public.posts; + + ---------------------------------------- + -- Test: each owner and admin phrase SHOULD be flagged on its own, an "all users" name that + -- claims ownership SHOULD be flagged, and USING (1=1) counts as always true for SELECT + ---------------------------------------- + create policy "Owners can view posts" on public.posts for select to authenticated using (true); + create policy "Only owners can read" on public.posts for select to authenticated using (true); + create policy "Readable by only the owner" on public.posts for select to authenticated using (true); + create policy "Read access for the owner" on public.posts for select to authenticated using (true); + create policy "Owner only read" on public.posts for select to authenticated using (true); + create policy "Visible to only admins" on public.posts for select to authenticated using (true); + create policy "All users can view their own posts" on public.posts for select to authenticated using (true); + create policy "Users can view own posts" on public.posts for select to authenticated using (1=1); + + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + + drop policy "Owners can view posts" on public.posts; + drop policy "Only owners can read" on public.posts; + drop policy "Readable by only the owner" on public.posts; + drop policy "Read access for the owner" on public.posts; + drop policy "Owner only read" on public.posts; + drop policy "Visible to only admins" on public.posts; + drop policy "All users can view their own posts" on public.posts; + drop policy "Users can view own posts" on public.posts; + + ---------------------------------------- + -- Test: "for admin" only counts at the end of the name, and "everyone" blocks a scope word + ---------------------------------------- + create policy "Authenticated can read for admin reports" on public.posts for select to authenticated using (true); + create policy "Everyone can view their posts" on public.posts for select using (true); + + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + + drop policy "Authenticated can read for admin reports" on public.posts; + drop policy "Everyone can view their posts" on public.posts; + + ---------------------------------------- + -- Test: each word after "admin" SHOULD be flagged on its own + -- ("can", "reads" and "select" are covered above) + ---------------------------------------- + create policy "Admin only" on public.posts for select to authenticated using (true); + create policy "Admin read posts" on public.posts for select to authenticated using (true); + create policy "Admin view" on public.posts for select to authenticated using (true); + create policy "Admin views posts" on public.posts for select to authenticated using (true); + create policy "Admins see posts" on public.posts for select to authenticated using (true); + create policy "Admin sees posts" on public.posts for select to authenticated using (true); + create policy "Admins manage posts" on public.posts for select to authenticated using (true); + create policy "Admin manages posts" on public.posts for select to authenticated using (true); + + select metadata->>'policy_name' as policy_name from lint."0024_rls_policy_always_true"; + + drop policy "Admin only" on public.posts; + drop policy "Admin read posts" on public.posts; + drop policy "Admin view" on public.posts; + drop policy "Admin views posts" on public.posts; + drop policy "Admins see posts" on public.posts; + drop policy "Admin sees posts" on public.posts; + drop policy "Admins manage posts" on public.posts; + drop policy "Admin manages posts" on public.posts; + + ---------------------------------------- + -- Test: write policies named "admin insert/update/delete" get the name note in detail, + -- and a write policy with a neutral name does not + ---------------------------------------- + create policy "admin_insert_posts" on public.posts for insert to authenticated with check (true); + create policy "admin_update_posts" on public.posts for update to authenticated using (true); + create policy "admin_delete_posts" on public.posts for delete to authenticated using (true); + create policy "update_any_post" on public.posts for update to authenticated using (true); + + select metadata->>'policy_name' as policy_name, detail from lint."0024_rls_policy_always_true"; + + drop policy "admin_insert_posts" on public.posts; + drop policy "admin_update_posts" on public.posts; + drop policy "admin_delete_posts" on public.posts; + drop policy "update_any_post" on public.posts; + + ---------------------------------------- + -- Test: SELECT whose name claims ownership and whose USING checks the owner should NOT be flagged + ---------------------------------------- + create policy "Users can view their own posts" + on public.posts + for select + to authenticated + using (user_id = auth.uid()); + + select count(*) from lint."0024_rls_policy_always_true"; + + drop policy "Users can view their own posts" on public.posts; + + ---------------------------------------- + -- Test: write policies whose name claims ownership get the name note in detail + -- when nothing in the policy checks the caller + ---------------------------------------- + create policy "Users can update their own posts" + on public.posts + for update + to authenticated + using (true); + + create policy "Users can insert their own posts" + on public.posts + for insert + to authenticated + with check (true); + + select metadata->>'policy_name' as policy_name, detail from lint."0024_rls_policy_always_true"; + + drop policy "Users can update their own posts" on public.posts; + drop policy "Users can insert their own posts" on public.posts; + + ---------------------------------------- + -- Test: UPDATE policies where one clause is always true and the other checks the owner + -- are still flagged, without the name note, because part of the policy does check the caller + ---------------------------------------- + create policy "Users can update their own posts" + on public.posts + for update + to authenticated + using (user_id = auth.uid()) + with check (true); + + create policy "Owners can update posts" + on public.posts + for update + to authenticated + using (true) + with check (user_id = auth.uid()); + + select metadata->>'policy_name' as policy_name, detail from lint."0024_rls_policy_always_true"; + + drop policy "Users can update their own posts" on public.posts; + drop policy "Owners can update posts" on public.posts; + + ---------------------------------------- + -- Test: a DELETE policy with no USING is still flagged, but without the name note, + -- since a missing USING only counts as "checks nobody" for INSERT + ---------------------------------------- + create policy "Users can delete their own posts" + on public.posts + for delete + to authenticated; + + select metadata->>'policy_name' as policy_name, detail from lint."0024_rls_policy_always_true"; + + drop policy "Users can delete their own posts" on public.posts; + rollback;