Skip to content

[security] Realtime + Storage anon-key surface bypasses route-code authorization (RLS / Realtime-Auth gap) #231

@Jose-Gael-Cruz-Lopez

Description

@Jose-Gael-Cruz-Lopez

Summary

The backend enforces ownership entirely in route code (require_self + user_id-scoped queries) using the service-role key, so PostgreSQL RLS is disabled project-wide (docs/architecture.md, docs/decisions/0017). But the frontend talks to Supabase directly with the public anon key for realtime and storage — paths that never touch route code and therefore have no authorization. The room_id/message filters used client-side are conveniences, not security boundaries.

Discovered while fixing #124 (realtime chat rendered ciphertext). #124 fixes the display; this issue is the authorization model behind it.

Severity: metadata-only for realtimeroom_messages.text / room_reactions content the attacker would receive is column-encrypted (ciphertext), so message bodies stay protected. What leaks is metadata (who posted/reacted, when, in which room, reply/edit/delete activity, presence/typing) for arbitrary rooms the user isn't a member of. Storage is a separate anon-write / public-read concern.

Full anon-key surface audit (every route-code-bypassing path)

Client obtained via getSupabase() (NEXT_PUBLIC_SUPABASE_ANON_KEY).

Realtime (frontend/src/components/screens/Social.tsx):

  1. postgres_changes INSERT/UPDATE on room_messages, filtered client-side by room_id=eq.<roomId> — the filter is attacker-controlled; any room's message stream is subscribable.
  2. postgres_changes INSERT/DELETE on room_reactionsno room filter at all (only a client-side "is this message loaded" check), so reaction events for all rooms stream to any subscriber. Broadest exposure.
  3. Presence channel presence:<roomId> (typing indicators) — also unauthenticated.

Storage (anon-key upload + public getPublicUrl):
4. Social.tsxchat-images bucket (chat attachments).
5. ReportIssueFlow.tsx → issue-report screenshots bucket.
6. Admin.tsx → cosmetics bucket.

The frontend-audit docs already flag "Verify bucket RLS policy" / "Bucket permissions must allow anon writes." Risk: anon upload abuse + public read of all objects.

No direct anon-key table queries exist — all DB reads/writes go through FastAPI (route-code enforced). The gap is bounded to realtime + storage above.

Membership model

room_members(room_id, user_id) exists (supabase_schema.sql:248) and is the natural scope for policies.

Proposed fix (NOT yet implemented — for architectural review)

  1. Enable RLS on room_messages, room_reactions, room_members, and presence-relevant tables, scoped to room membership (EXISTS (SELECT 1 FROM room_members m WHERE m.room_id = <row>.room_id AND m.user_id = auth.uid())). The service-role backend bypasses RLS, so route code is unaffected; only the anon/realtime path is constrained.
  2. Realtime Authorization (private channels) instead of public broadcast: gate realtime.messages by the same membership policy so the server only streams events for rooms the user belongs to.
  3. Authenticate the realtime client with the user's JWT, not the anon key. This is the crux/decision: the app uses its own HMAC session, not Supabase Auth. Options:
    • (a) Mint a Supabase-compatible JWT per user (signed with the Supabase JWT secret, carrying the user id as sub) at login; hand it to the realtime client and key RLS on auth.uid(). Smallest bridge.
    • (b) Adopt Supabase Auth wholesale (larger change).
    • (c) Proxy realtime through the backend (e.g. backend-driven SSE) and drop the direct anon-key client entirely — eliminates the surface but is a chat rebuild.
  4. Storage bucket policies: require authenticated uploads (kill anon write abuse); scope reads (chat-images likely needs public read for <img>; issue/cosmetic reconsidered).

Decision needed before building

  • Which realtime-auth bridge: mint Supabase JWTs (a), adopt Supabase Auth (b), or backend-proxy realtime (c)?
  • Storage: per-bucket read/write policy intent.

Do not implement until the approach is chosen.

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend / APIfrontendFrontend / UIsecuritySecurity / data exposure

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions