-
-
Notifications
You must be signed in to change notification settings - Fork 71
fix(rls): fail fast anon REST scans #1595
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,287 @@ | ||
| -- Fail fast for unauthenticated PostgREST queries and avoid per-row API key resolution in RLS. | ||
| -- | ||
| -- Goal: | ||
| -- - Unauthenticated anon requests (no capgkey header, auth.uid() is NULL) must not scan large tables. | ||
| -- - Policies should be index-friendly for common predicates (e.g. app_id IN allowed_app_ids()). | ||
| -- | ||
| -- Context: | ||
| -- PostgREST requests with the public anon key can hit RLS policies. If the policy evaluates expensive | ||
| -- identity/auth logic per row (e.g. get_identity_org_appid(owner_org, app_id)), an unfiltered query can | ||
| -- trigger statement_timeouts and cause cascading failures. | ||
|
|
||
| -- 1) Statement-scoped guard: true only when the request is authenticated OR carries a valid Capgo API key. | ||
| CREATE OR REPLACE FUNCTION "public"."has_auth_or_valid_apikey"("keymode" "public"."key_mode"[]) | ||
| RETURNS boolean | ||
| LANGUAGE "plpgsql" STABLE SECURITY DEFINER | ||
| SET "search_path" TO '' | ||
| AS $$ | ||
| DECLARE | ||
| v_user_id uuid; | ||
| v_api_key_text text; | ||
| v_api_key record; | ||
| BEGIN | ||
| SELECT auth.uid() INTO v_user_id; | ||
| IF v_user_id IS NOT NULL THEN | ||
| RETURN true; | ||
| END IF; | ||
|
|
||
| SELECT public.get_apikey_header() INTO v_api_key_text; | ||
| IF v_api_key_text IS NULL THEN | ||
| RETURN false; | ||
| END IF; | ||
|
|
||
| SELECT * FROM public.find_apikey_by_value(v_api_key_text) INTO v_api_key; | ||
| IF v_api_key.id IS NULL THEN | ||
| RETURN false; | ||
| END IF; | ||
|
|
||
| IF NOT (v_api_key.mode = ANY(keymode)) THEN | ||
| RETURN false; | ||
| END IF; | ||
|
|
||
| IF public.is_apikey_expired(v_api_key.expires_at) THEN | ||
| RETURN false; | ||
| END IF; | ||
|
|
||
| RETURN true; | ||
| END; | ||
| $$; | ||
|
|
||
| -- NOTE: GRANT/REVOKE must specify only argument types (not parameter names). | ||
| GRANT EXECUTE ON FUNCTION "public"."has_auth_or_valid_apikey"("public"."key_mode"[]) TO "anon"; | ||
| GRANT EXECUTE ON FUNCTION "public"."has_auth_or_valid_apikey"("public"."key_mode"[]) TO "authenticated"; | ||
|
|
||
| -- 2) Compute readable app_ids once per statement, then let policies use a simple index predicate: | ||
| -- app_id = ANY(allowed_read_apps()). | ||
| CREATE OR REPLACE FUNCTION "public"."allowed_read_apps"() | ||
| RETURNS text[] | ||
| LANGUAGE "plpgsql" STABLE SECURITY DEFINER | ||
| SET "search_path" TO '' | ||
| AS $$ | ||
| DECLARE | ||
| v_user_id uuid; | ||
| v_api_key_text text; | ||
| v_api_key public.apikeys%ROWTYPE; | ||
| v_allowed text[] := '{}'::text[]; | ||
| v_app record; | ||
| v_use_rbac boolean; | ||
| v_perm text := public.rbac_permission_for_legacy( | ||
| 'read'::public.user_min_right, | ||
| public.rbac_scope_app() | ||
| ); | ||
| v_enforcing_2fa boolean; | ||
| BEGIN | ||
| SELECT auth.uid() INTO v_user_id; | ||
|
|
||
| -- IMPORTANT: If the request is already authenticated, ignore any provided API key. | ||
| -- This preserves the historical behavior where a stray `capgkey` header does not | ||
| -- reduce the user's access compared to a normal authenticated request. | ||
| IF v_user_id IS NULL THEN | ||
| SELECT public.get_apikey_header() INTO v_api_key_text; | ||
| IF v_api_key_text IS NOT NULL THEN | ||
| SELECT * FROM public.find_apikey_by_value(v_api_key_text) INTO v_api_key; | ||
| IF v_api_key.id IS NOT NULL | ||
| AND v_api_key.mode = ANY('{read,upload,write,all}'::public.key_mode[]) | ||
| AND NOT public.is_apikey_expired(v_api_key.expires_at) | ||
| THEN | ||
| v_user_id := v_api_key.user_id; | ||
| ELSE | ||
| -- Treat invalid/mismatched/expired keys as absent (fail closed). | ||
| v_api_key := NULL; | ||
| END IF; | ||
| END IF; | ||
| END IF; | ||
|
|
||
| -- No auth and no usable API key. | ||
| IF v_user_id IS NULL AND v_api_key.id IS NULL THEN | ||
| RETURN v_allowed; | ||
| END IF; | ||
|
|
||
| -- Candidate apps come from: | ||
| -- - legacy org_users bindings (org-wide or app-wide, but not channel bindings) | ||
| -- - RBAC org/app bindings (user principal or apikey principal) | ||
| FOR v_app IN | ||
| SELECT DISTINCT a.app_id, a.owner_org | ||
| FROM public.apps a | ||
| WHERE | ||
| -- Legacy org membership / app access. | ||
| EXISTS ( | ||
| SELECT 1 | ||
| FROM public.org_users ou | ||
| WHERE ou.user_id = v_user_id | ||
| AND ou.org_id = a.owner_org | ||
| AND ou.channel_id IS NULL | ||
| AND (ou.app_id IS NULL OR ou.app_id = a.app_id) | ||
| ) | ||
| OR | ||
| -- RBAC: org-level bindings (implies possible access across apps via inheritance). | ||
| EXISTS ( | ||
| SELECT 1 | ||
| FROM public.role_bindings rb | ||
| WHERE rb.scope_type = public.rbac_scope_org() | ||
| AND rb.org_id = a.owner_org | ||
| AND ( | ||
| (rb.principal_type = public.rbac_principal_user() AND rb.principal_id = v_user_id) | ||
| OR | ||
| (v_api_key.rbac_id IS NOT NULL AND rb.principal_type = public.rbac_principal_apikey() AND rb.principal_id = v_api_key.rbac_id) | ||
| ) | ||
| ) | ||
| OR | ||
| -- RBAC: app-level bindings (apps.id is the RBAC scope identifier). | ||
| EXISTS ( | ||
| SELECT 1 | ||
| FROM public.role_bindings rb | ||
| WHERE rb.scope_type = public.rbac_scope_app() | ||
| AND rb.app_id = a.id | ||
| AND ( | ||
| (rb.principal_type = public.rbac_principal_user() AND rb.principal_id = v_user_id) | ||
| OR | ||
| (v_api_key.rbac_id IS NOT NULL AND rb.principal_type = public.rbac_principal_apikey() AND rb.principal_id = v_api_key.rbac_id) | ||
| ) | ||
| ) | ||
| LOOP | ||
| -- Enforce API key scoping (if present). | ||
| IF v_api_key.id IS NOT NULL | ||
| AND COALESCE(array_length(v_api_key.limited_to_orgs, 1), 0) > 0 | ||
| AND NOT (v_app.owner_org = ANY(v_api_key.limited_to_orgs)) | ||
| THEN | ||
| CONTINUE; | ||
| END IF; | ||
|
|
||
| IF v_api_key.id IS NOT NULL | ||
| AND v_api_key.limited_to_apps IS DISTINCT FROM '{}' | ||
| AND NOT (v_app.app_id = ANY(v_api_key.limited_to_apps)) | ||
| THEN | ||
| CONTINUE; | ||
| END IF; | ||
|
|
||
| v_use_rbac := public.rbac_is_enabled_for_org(v_app.owner_org); | ||
|
|
||
| IF NOT v_use_rbac THEN | ||
| -- Legacy rights (includes org 2FA + password policy checks). | ||
| IF public.check_min_rights_legacy( | ||
| 'read'::public.user_min_right, | ||
| v_user_id, | ||
| v_app.owner_org, | ||
| v_app.app_id, | ||
| NULL::bigint | ||
| ) THEN | ||
| v_allowed := array_append(v_allowed, v_app.app_id); | ||
| END IF; | ||
| ELSE | ||
| -- Mirror check_min_rights() org gating for RBAC orgs (2FA + password policy). | ||
| SELECT o.enforcing_2fa INTO v_enforcing_2fa | ||
| FROM public.orgs o | ||
| WHERE o.id = v_app.owner_org; | ||
|
|
||
| IF v_enforcing_2fa = true AND (v_user_id IS NULL OR NOT public.has_2fa_enabled(v_user_id)) THEN | ||
| CONTINUE; | ||
| END IF; | ||
|
|
||
| IF NOT public.user_meets_password_policy(v_user_id, v_app.owner_org) THEN | ||
| CONTINUE; | ||
| END IF; | ||
|
|
||
| -- Allow if the user or the API key principal has the required RBAC permission. | ||
| IF v_user_id IS NOT NULL | ||
| AND public.rbac_has_permission( | ||
| public.rbac_principal_user(), | ||
| v_user_id, | ||
| v_perm, | ||
| v_app.owner_org, | ||
| v_app.app_id, | ||
| NULL::bigint | ||
| ) | ||
| THEN | ||
| v_allowed := array_append(v_allowed, v_app.app_id); | ||
| ELSIF v_api_key.id IS NOT NULL | ||
| AND v_api_key.rbac_id IS NOT NULL | ||
| AND public.rbac_has_permission( | ||
| public.rbac_principal_apikey(), | ||
| v_api_key.rbac_id, | ||
| v_perm, | ||
| v_app.owner_org, | ||
| v_app.app_id, | ||
| NULL::bigint | ||
| ) | ||
| THEN | ||
| v_allowed := array_append(v_allowed, v_app.app_id); | ||
| END IF; | ||
| END IF; | ||
| END LOOP; | ||
|
|
||
| RETURN v_allowed; | ||
| END; | ||
| $$; | ||
|
|
||
| GRANT EXECUTE ON FUNCTION "public"."allowed_read_apps"() TO "anon"; | ||
| GRANT EXECUTE ON FUNCTION "public"."allowed_read_apps"() TO "authenticated"; | ||
|
|
||
| -- 3) Apply fail-fast + index-friendly policies on the largest affected tables. | ||
|
|
||
| -- audit_logs: keep org_id predicate but add a one-time guard so unauthenticated anon requests do not scan. | ||
| DROP POLICY IF EXISTS "Allow select for auth, api keys (super_admin+)" ON "public"."audit_logs"; | ||
| CREATE POLICY "Allow select for auth, api keys (super_admin+)" ON "public"."audit_logs" | ||
| FOR SELECT TO "anon", "authenticated" | ||
| USING ( | ||
| "org_id" = ANY("public"."audit_logs_allowed_orgs"()) | ||
| AND public.has_auth_or_valid_apikey('{read,upload,write,all}'::public.key_mode[]) | ||
| ); | ||
|
|
||
| -- app_versions + app_versions_meta: avoid per-row identity resolution; use allowed_read_apps(). | ||
| DROP POLICY IF EXISTS "Allow for auth, api keys (read+)" ON "public"."app_versions"; | ||
| CREATE POLICY "Allow for auth, api keys (read+)" ON "public"."app_versions" | ||
| FOR SELECT TO "anon", "authenticated" | ||
| USING ( | ||
| "app_id" = ANY("public"."allowed_read_apps"()) | ||
| AND public.has_auth_or_valid_apikey('{read,upload,write,all}'::public.key_mode[]) | ||
| ); | ||
|
|
||
| DROP POLICY IF EXISTS "Allow read for auth (read+)" ON "public"."app_versions_meta"; | ||
| CREATE POLICY "Allow read for auth (read+)" ON "public"."app_versions_meta" | ||
| FOR SELECT TO "anon", "authenticated" | ||
| USING ( | ||
| "app_id" = ANY("public"."allowed_read_apps"()) | ||
| AND public.has_auth_or_valid_apikey('{read,upload,write,all}'::public.key_mode[]) | ||
| ); | ||
|
|
||
| -- 4) (Optional hardening) Replace common read policies to avoid per-row get_identity_org_appid() on large tables. | ||
| -- NOTE: This intentionally deviates from the "use get_identity_org_appid() in every policy" convention | ||
| -- for large tables, because per-row identity resolution is the root cause of the PostgREST DoS vector. | ||
| -- The statement-scoped helpers keep the same security checks, but compute them once per statement. | ||
| -- apps | ||
| DROP POLICY IF EXISTS "Allow for auth, api keys (read+)" ON "public"."apps"; | ||
| CREATE POLICY "Allow for auth, api keys (read+)" ON "public"."apps" | ||
| FOR SELECT TO "anon", "authenticated" | ||
| USING ( | ||
| "app_id" = ANY("public"."allowed_read_apps"()) | ||
| AND public.has_auth_or_valid_apikey('{read,upload,write,all}'::public.key_mode[]) | ||
| ); | ||
|
|
||
| -- channels | ||
| DROP POLICY IF EXISTS "Allow select for auth, api keys (read+)" ON "public"."channels"; | ||
| CREATE POLICY "Allow select for auth, api keys (read+)" ON "public"."channels" | ||
| FOR SELECT TO "anon", "authenticated" | ||
| USING ( | ||
| "app_id" = ANY("public"."allowed_read_apps"()) | ||
| AND public.has_auth_or_valid_apikey('{read,upload,write,all}'::public.key_mode[]) | ||
| ); | ||
|
|
||
| -- channel_devices | ||
| DROP POLICY IF EXISTS "Allow read for auth, api keys (read+)" ON "public"."channel_devices"; | ||
| DROP POLICY IF EXISTS "Allow read for auth (read+)" ON "public"."channel_devices"; | ||
| CREATE POLICY "Allow read for auth, api keys (read+)" ON "public"."channel_devices" | ||
| FOR SELECT TO "anon", "authenticated" | ||
| USING ( | ||
| "app_id" = ANY("public"."allowed_read_apps"()) | ||
| AND public.has_auth_or_valid_apikey('{read,upload,write,all}'::public.key_mode[]) | ||
| ); | ||
|
|
||
| -- build_requests | ||
| DROP POLICY IF EXISTS "Allow org members to select build_requests" ON "public"."build_requests"; | ||
| CREATE POLICY "Allow org members to select build_requests" ON "public"."build_requests" | ||
| FOR SELECT TO "anon", "authenticated" | ||
| USING ( | ||
| "app_id" = ANY("public"."allowed_read_apps"()) | ||
| AND public.has_auth_or_valid_apikey('{read,upload,write,all}'::public.key_mode[]) | ||
| ); | ||
|
Comment on lines
220
to
287
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Search for app_id column definitions in channel_devices and build_requests tables
# Look through migrations to find table definitions
find supabase/migrations -name "*.sql" -type f | head -20Repository: Cap-go/capgo Length of output: 1251 🏁 Script executed: #!/bin/bash
# Search for channel_devices table definition
rg -A 20 'CREATE TABLE.*channel_devices' supabase/migrations/Repository: Cap-go/capgo Length of output: 1646 🏁 Script executed: #!/bin/bash
# Search for build_requests table definition
rg -A 20 'CREATE TABLE.*build_requests' supabase/migrations/Repository: Cap-go/capgo Length of output: 2354 🏁 Script executed: #!/bin/bash
# Search for allowed_read_apps function definition
rg -B 5 -A 15 'allowed_read_apps' supabase/migrations/ --type sql | head -50Repository: Cap-go/capgo Length of output: 5113 🏁 Script executed: #!/bin/bash
# Search for allowed_read_apps function in schema definitions
rg 'allowed_read_apps' supabase/Repository: Cap-go/capgo Length of output: 1834 🏁 Script executed: #!/bin/bash
# Get the full allowed_read_apps function definition
rg -A 60 'CREATE OR REPLACE FUNCTION.*allowed_read_apps' supabase/migrations/20260206123000_fail_fast_postgrest_rls.sqlRepository: Cap-go/capgo Length of output: 2000 🏁 Script executed: #!/bin/bash
# Check if there's any reference to get_identity_org_appid in the migration
rg 'get_identity_org_appid' supabase/migrations/20260206123000_fail_fast_postgrest_rls.sqlRepository: Cap-go/capgo Length of output: 275 🏁 Script executed: #!/bin/bash
# Check what the original policies looked like before this migration
rg 'get_identity_org_appid' supabase/migrations/ --type sql | head -20Repository: Cap-go/capgo Length of output: 2682 🏁 Script executed: #!/bin/bash
# Get the full allowed_read_apps function to see complete implementation
sed -n '/CREATE OR REPLACE FUNCTION.*allowed_read_apps/,/^$$;/p' supabase/migrations/20260206123000_fail_fast_postgrest_rls.sqlRepository: Cap-go/capgo Length of output: 5093 RLS policies deviate from mandatory guidelines; The policies correctly identify that both While Either:
🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In
allowed_read_apps, API key scoping is applied whenever a validcapgkeyheader is present, even ifauth.uid()is already set (the function always loads the key and then applies thelimited_to_orgs/appsfilters). This is a behavior change from the previousget_identity_org_appid-based policies, which ignored API keys once a user session was present. As a result, an authenticated user who sends a limited API key header (e.g., a client that always includescapgkey) can lose access to apps they otherwise can read. Consider only applying API key scoping when the request is unauthenticated or when you explicitly want key-scoped access to override user rights.Useful? React with 👍 / 👎.