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 realtime — room_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):
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.
postgres_changes INSERT/DELETE on room_reactions — no 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.
- Presence channel
presence:<roomId> (typing indicators) — also unauthenticated.
Storage (anon-key upload + public getPublicUrl):
4. Social.tsx → chat-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)
- 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.
- 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.
- 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.
- 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.
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. Theroom_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 realtime —
room_messages.text/room_reactionscontent 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):postgres_changesINSERT/UPDATE onroom_messages, filtered client-side byroom_id=eq.<roomId>— the filter is attacker-controlled; any room's message stream is subscribable.postgres_changesINSERT/DELETE onroom_reactions— no 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.presence:<roomId>(typing indicators) — also unauthenticated.Storage (anon-key upload + public getPublicUrl):
4.
Social.tsx→chat-imagesbucket (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)
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.realtime.messagesby the same membership policy so the server only streams events for rooms the user belongs to.sub) at login; hand it to the realtime client and key RLS onauth.uid(). Smallest bridge.<img>; issue/cosmetic reconsidered).Decision needed before building
Do not implement until the approach is chosen.