diff --git a/bin/installcheck b/bin/installcheck index e17d27c..a9274dd 100755 --- a/bin/installcheck +++ b/bin/installcheck @@ -49,7 +49,12 @@ 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 -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/0012*.sql -f lints/0013*.sql -d contrib_regression # Run tests ${REGRESS} --use-existing --dbname=contrib_regression --inputdir=${TESTDIR} ${TESTS} + +if [ -s /home/splinter/regression.diffs ] +then + cat /home/splinter/regression.diffs +fi diff --git a/docs/0012_auth_allow_anonymous_sign_ins.md b/docs/0012_auth_allow_anonymous_sign_ins.md new file mode 100644 index 0000000..9b24ec6 --- /dev/null +++ b/docs/0012_auth_allow_anonymous_sign_ins.md @@ -0,0 +1,31 @@ +Level: INFO + +### Rationale + +Anonymous users use the same `authenticated` Postgres role as permanent users when accessing the database. If you have enabled anonymous sign-in for your project, existing RLS policies may allow unintended access to an anonymous user's JWT. + +### Difference between an anonymous user and a permanent user + +An anonymous user is a user created through Supabase Auth. It is just like a permanent user, except the user can't access their account if they sign out, clear browsing data or use another device. An anonymous user can be differentiated from a permanent user by checking if the `is_anonymous` claim is true. These claims are returned by the `auth.jwt()` function. + +### How to Resolve + +Determine if existing row level security (RLS) policies are meant to allow access to anonymous users. Affected policies include those that are associated to the `authenticated` or `public` roles, and members of those roles that inherit privileges. + +For example, consider the policy: + +```sql +create policy "allow_access_to_authenticated" on documents +as restrictive +to authenticated +using (true); +``` + +In this policy, any JWT that contains the authenticated role will be allowed to access the documents table. If we want to restrict access to permanent users only, we can modify the policy to: + +```sql +create policy "allow_access_to_permanent_users" on documents +as restrictive +to authenticated +using ( (select (auth.jwt()->>'is_anonymous')::boolean) is false ); +``` diff --git a/lints/0012_auth_allow_anonymous_sign_ins.sql b/lints/0012_auth_allow_anonymous_sign_ins.sql new file mode 100644 index 0000000..fa5d7e6 --- /dev/null +++ b/lints/0012_auth_allow_anonymous_sign_ins.sql @@ -0,0 +1,56 @@ +create view lint."0012_auth_allow_anonymous_sign_ins" as + +with recursive role_members as ( + select + roleid, + member + from pg_catalog.pg_auth_members + where roleid = (select oid from pg_roles where rolname = 'authenticated') + union + select + am.roleid, + am.member + from pg_catalog.pg_auth_members as am + inner join role_members as rm on am.roleid = rm.member +), + +member_names as ( + select r.rolname from pg_roles as r + inner join role_members as m on r.oid = m.member +) + +select + 'auth_allow_anonymous_sign_ins' as name, + 'INFO' as level, + 'EXTERNAL' as facing, + 'Detects row level security (RLS) policies that allow access to anonymous users.' as description, + 'https://supabase.github.io/splinter/0012_auth_allow_anonymous_sign_ins' as remediation, + format( + 'Table \`%s.%s\` has policies enforced on roles that allow access to anonymous users. Policies include \`%s\`', + n.nspname, + c.relname, + array_agg(p.policyname order by p.policyname) + ) as detail, + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as metadata, + format( + 'auth_allow_anonymous_sign_ins_%s_%s', + n.nspname, + c.relname + ) as cache_key +from pg_catalog.pg_policies as p +inner join pg_catalog.pg_class as c on p.tablename = c.relname +inner join pg_catalog.pg_namespace as n on c.relnamespace = n.oid +where + ( + p.roles = array['public'::name] -- public roles + or p.roles = array['authenticated'::name] -- authenticated roles + or exists ( + select rolname from member_names where rolname = any(roles) + ) -- roles that are members of authenticated + ) + and replace(p.qual, ' ', '') not like '%auth.jwt()%->>%is_anonymous%' +group by n.nspname, c.relname diff --git a/splinter.json b/splinter.json index 5ad4c37..95577f3 100644 --- a/splinter.json +++ b/splinter.json @@ -10,5 +10,6 @@ "0009_duplicate_index": "(\nselect\n 'duplicate_index' as name,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n 'Detects cases where two ore more identical indexes exist.' as description,\n format(\n 'Table \\`%s.%s\\` has identical indexes %s. Drop all except one of them',\n n.nspname,\n c.relname,\n array_agg(pi.indexname order by pi.indexname)\n ) as detail,\n 'https://supabase.github.io/splinter/0009_duplicate_index' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', case\n when c.relkind = 'r' then 'table'\n when c.relkind = 'm' then 'materialized view'\n else 'ERROR'\n end,\n 'indexes', array_agg(pi.indexname order by pi.indexname)\n ) as metadata,\n format(\n 'duplicate_index_%s_%s_%s',\n n.nspname,\n c.relname,\n array_agg(pi.indexname order by pi.indexname)\n ) as cache_key\nfrom\n pg_catalog.pg_indexes pi\n join pg_catalog.pg_namespace n\n on n.nspname = pi.schemaname\n join pg_catalog.pg_class c\n on pi.tablename = c.relname\n and n.oid = c.relnamespace\n left join pg_catalog.pg_policy p\n on p.polrelid = c.oid\nwhere\n c.relkind in ('r', 'm') -- tables and materialized views\n and n.nspname not in (\n 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'pgsodium'\n )\ngroup by\n n.nspname,\n c.relkind,\n c.relname,\n replace(pi.indexdef, pi.indexname, '')\nhaving\n count(*) > 1)", "0010_security_definer_view": "(\nselect\n 'security_definer_view' as name,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n 'Detects views that are SECURITY DEFINER meaning that they ignore row level security (RLS) policies.' as description,\n format(\n 'View \\`%s.%s\\` is SECURITY DEFINER',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.github.io/splinter/0010_security_definer_view' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'view'\n ) as metadata,\n format(\n 'security_definer_view_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on n.oid = c.relnamespace\nwhere\n c.relkind = 'v'\n and n.nspname = 'public'\n\tand not (\n\t\tlower(coalesce(c.reloptions::text,'{}'))::text[]\n\t\t&& array[\n\t\t\t'security_invoker=1',\n\t\t\t'security_invoker=true',\n\t\t\t'security_invoker=yes',\n\t\t\t'security_invoker=on'\n\t\t]\n\t))", "0011_function_search_path_mutable": "(\nselect\n 'function_search_path_mutable' as name,\n 'WARN' as level,\n 'EXTERNAL' as facing,\n 'Detects functions with a mutable search_path parameter which could fail to execute sucessfully for some roles.' as description,\n format(\n 'Function \\`%s.%s\\` has a role mutable search_path',\n n.nspname,\n p.proname\n ) as detail,\n 'https://supabase.github.io/splinter/0011_function_search_path_mutable' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', p.proname,\n 'type', 'function'\n ) as metadata,\n format(\n 'function_search_path_mutable_%s_%s_%s',\n n.nspname,\n p.proname,\n md5(p.prosrc) -- required when function is polymorphic\n ) as cache_key\nfrom\n pg_catalog.pg_proc p\n join pg_catalog.pg_namespace n\n on p.pronamespace = n.oid\nwhere\n n.nspname not in (\n 'pg_catalog', 'information_schema', 'auth', 'storage', 'vault', 'pgsodium', 'graphql', 'graphql_public'\n )\n -- Search path not set to ''\n and not coalesce(p.proconfig, '{}') && array['search_path=\"\"'])", + "0012_auth_allow_anonymous_sign_ins": "(\nwith recursive role_members as (\n select\n roleid,\n member\n from pg_catalog.pg_auth_members\n where roleid = (select oid from pg_roles where rolname = 'authenticated')\n union\n select\n am.roleid,\n am.member\n from pg_catalog.pg_auth_members as am\n inner join role_members as rm on am.roleid = rm.member\n),\n\nmember_names as (\n select r.rolname from pg_roles as r\n inner join role_members as m on r.oid = m.member\n)\n\nselect\n 'auth_allow_anonymous_sign_ins' as name,\n 'INFO' as level,\n 'EXTERNAL' as facing,\n 'Detects row level security (RLS) policies that allow access to anonymous users.' as description,\n 'https://supabase.github.io/splinter/0012_auth_allow_anonymous_sign_ins' as remediation,\n format(\n 'Table \\`%s.%s\\` has policies enforced on roles that allow access to anonymous users. Policies include \\`%s\\`',\n n.nspname,\n c.relname,\n array_agg(p.policyname order by p.policyname)\n ) as detail,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'auth_allow_anonymous_sign_ins_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom pg_catalog.pg_policies as p\ninner join pg_catalog.pg_class as c on p.tablename = c.relname\ninner join pg_catalog.pg_namespace as n on c.relnamespace = n.oid\nwhere\n (\n p.roles = array['public'::name] -- public roles \n or p.roles = array['authenticated'::name] -- authenticated roles \n or exists (\n select rolname from member_names where rolname = any(roles)\n ) -- roles that are members of authenticated\n )\n and replace(p.qual, ' ', '') not like '%auth.jwt()%->>%is_anonymous%'\ngroup by n.nspname, c.relname)", "0013_rls_disabled_in_public": "(\nselect\n 'rls_disabled_in_public' as name,\n 'ERROR' as level,\n 'EXTERNAL' as facing,\n 'Detects cases where row level security (RLS) has not been enabled on a table in the `public` schema.' as description,\n format(\n 'Table \\`%s.%s\\` is in the `public` but RLS has not been enabled.',\n n.nspname,\n c.relname\n ) as detail,\n 'https://supabase.github.io/splinter/0013_rls_disabled_in_public' as remediation,\n jsonb_build_object(\n 'schema', n.nspname,\n 'name', c.relname,\n 'type', 'table'\n ) as metadata,\n format(\n 'rls_disabled_in_public_%s_%s',\n n.nspname,\n c.relname\n ) as cache_key\nfrom\n pg_catalog.pg_class c\n join pg_catalog.pg_namespace n\n on c.relnamespace = n.oid\nwhere\n c.relkind = 'r' -- regular tables\n and n.nspname = 'public'\n -- RLS is disabled\n and not c.relrowsecurity)" } \ No newline at end of file diff --git a/splinter.sql b/splinter.sql index 2d68c62..0b2745d 100644 --- a/splinter.sql +++ b/splinter.sql @@ -585,6 +585,62 @@ where and not coalesce(p.proconfig, '{}') && array['search_path=""']) union all ( +with recursive role_members as ( + select + roleid, + member + from pg_catalog.pg_auth_members + where roleid = (select oid from pg_roles where rolname = 'authenticated') + union + select + am.roleid, + am.member + from pg_catalog.pg_auth_members as am + inner join role_members as rm on am.roleid = rm.member +), + +member_names as ( + select r.rolname from pg_roles as r + inner join role_members as m on r.oid = m.member +) + +select + 'auth_allow_anonymous_sign_ins' as name, + 'INFO' as level, + 'EXTERNAL' as facing, + 'Detects row level security (RLS) policies that allow access to anonymous users.' as description, + 'https://supabase.github.io/splinter/0012_auth_allow_anonymous_sign_ins' as remediation, + format( + 'Table \`%s.%s\` has policies enforced on roles that allow access to anonymous users. Policies include \`%s\`', + n.nspname, + c.relname, + array_agg(p.policyname order by p.policyname) + ) as detail, + jsonb_build_object( + 'schema', n.nspname, + 'name', c.relname, + 'type', 'table' + ) as metadata, + format( + 'auth_allow_anonymous_sign_ins_%s_%s', + n.nspname, + c.relname + ) as cache_key +from pg_catalog.pg_policies as p +inner join pg_catalog.pg_class as c on p.tablename = c.relname +inner join pg_catalog.pg_namespace as n on c.relnamespace = n.oid +where + ( + p.roles = array['public'::name] -- public roles + or p.roles = array['authenticated'::name] -- authenticated roles + or exists ( + select rolname from member_names where rolname = any(roles) + ) -- roles that are members of authenticated + ) + and replace(p.qual, ' ', '') not like '%auth.jwt()%->>%is_anonymous%' +group by n.nspname, c.relname) +union all +( select 'rls_disabled_in_public' as name, 'ERROR' as level, diff --git a/test/expected/0012_auth_allow_anonymous_sign_ins.out b/test/expected/0012_auth_allow_anonymous_sign_ins.out new file mode 100644 index 0000000..d558334 --- /dev/null +++ b/test/expected/0012_auth_allow_anonymous_sign_ins.out @@ -0,0 +1,45 @@ +begin; + -- 0 issues + select * from lint."0012_auth_allow_anonymous_sign_ins"; + name | level | facing | description | remediation | detail | metadata | cache_key +------+-------+--------+-------------+-------------+--------+----------+----------- +(0 rows) + + create table public.documents( id int primary key ); + -- Create a policy for the authenticated role that would allow access to anonymous login users + -- if that feature is enabled + create policy "allow_access_to_authenticated" on public.documents + as restrictive + to authenticated + using (true); + -- 1 issues + select * from lint."0012_auth_allow_anonymous_sign_ins"; + name | level | facing | description | remediation | detail | metadata | cache_key +-------------------------------+-------+----------+---------------------------------------------------------------------------------+------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------+------------------------------------------------------------+------------------------------------------------ + auth_allow_anonymous_sign_ins | INFO | EXTERNAL | Detects row level security (RLS) policies that allow access to anonymous users. | https://supabase.github.io/splinter/0012_auth_allow_anonymous_sign_ins | Table \`public.documents\` has policies enforced on roles that allow access to anonymous users. Policies include \`{allow_access_to_authenticated}\` | {"name": "documents", "type": "table", "schema": "public"} | auth_allow_anonymous_sign_ins_public_documents +(1 row) + + drop policy "allow_access_to_authenticated" on public.documents; + -- Resolve the issue by excluding anonymous login users + create policy "allow_access_to_permanent_users" on documents + as restrictive + to authenticated + using ( (select (auth.jwt() ->> 'is_anonymous')::boolean) is false ); + -- 0 issues + select * from lint."0012_auth_allow_anonymous_sign_ins"; + name | level | facing | description | remediation | detail | metadata | cache_key +------+-------+--------+-------------+-------------+--------+----------+----------- +(0 rows) + + -- Check if policy definition passes with case sensitive characters + create policy "allow_access_to_permanent_users_case_senstive" on documents + as restrictive + to authenticated + using ( (select (AUTH.JWT() ->> 'is_anonymous')::boolean) is false ); + -- 0 issues + select * from lint."0012_auth_allow_anonymous_sign_ins"; + name | level | facing | description | remediation | detail | metadata | cache_key +------+-------+--------+-------------+-------------+--------+----------+----------- +(0 rows) + +rollback; diff --git a/test/sql/0012_auth_allow_anonymous_sign_ins.sql b/test/sql/0012_auth_allow_anonymous_sign_ins.sql new file mode 100644 index 0000000..0c4779c --- /dev/null +++ b/test/sql/0012_auth_allow_anonymous_sign_ins.sql @@ -0,0 +1,38 @@ +begin; + + -- 0 issues + select * from lint."0012_auth_allow_anonymous_sign_ins"; + + create table public.documents( id int primary key ); + + -- Create a policy for the authenticated role that would allow access to anonymous login users + -- if that feature is enabled + create policy "allow_access_to_authenticated" on public.documents + as restrictive + to authenticated + using (true); + + -- 1 issues + select * from lint."0012_auth_allow_anonymous_sign_ins"; + + drop policy "allow_access_to_authenticated" on public.documents; + + -- Resolve the issue by excluding anonymous login users + create policy "allow_access_to_permanent_users" on documents + as restrictive + to authenticated + using ( (select (auth.jwt() ->> 'is_anonymous')::boolean) is false ); + + -- 0 issues + select * from lint."0012_auth_allow_anonymous_sign_ins"; + + -- Check if policy definition passes with case sensitive characters + create policy "allow_access_to_permanent_users_case_senstive" on documents + as restrictive + to authenticated + using ( (select (AUTH.JWT() ->> 'is_anonymous')::boolean) is false ); + + -- 0 issues + select * from lint."0012_auth_allow_anonymous_sign_ins"; + +rollback;