Cross-user private file-content disclosure via unvalidated fileIds in the retrieval API (Supabase RLS bypass)
Summary
The retrieval endpoint matches embeddings against a caller-supplied list of file UUIDs using the Supabase service-role client, which bypasses row-level security, and the matching RPC never re-checks file ownership. Any authenticated low-privilege user can therefore read the indexed content of another user's private uploaded files by supplying that file's UUID. File UUIDs are not secrets (they are shared with workspace collaborators and embedded in shared chats). Confirmed against the real migration schema, RLS policies, and the real match_file_items_local RPC: a victim's private content was returned to an attacker through the service-role path while a direct RLS-scoped read returned nothing.
Details
app/api/retrieval/retrieve/route.ts:
const supabaseAdmin = createClient<Database>(URL, process.env.SUPABASE_SERVICE_ROLE_KEY!) // ~19-22, BYPASSRLS client
...
const { fileIds } = json as { ... fileIds: string[] } // ~9-16, attacker-controlled body
...
await supabaseAdmin.rpc("match_file_items_openai", {
query_embedding: openaiEmbedding,
match_count: sourceCount,
file_ids: uniqueFileIds // ~60-64 (and ~75-79 for local embeddings)
})The only authorization gate is getServerProfile() (~line 24), which requires any logged-in user with no role or ownership check (lib/server/server-chat-helpers.ts:6-37). The route then queries through supabaseAdmin, the service-role client, which has BYPASSRLS. The RPC bodies (supabase/migrations/20240108234545_add_file_items.sql:60-115) filter only on where file_id = ANY(file_ids), with no user_id = auth.uid() predicate. The file_items table does carry correct RLS (own items, or non-private files; migration ~39-49), but RLS is irrelevant on a service-role connection and the function never re-checks ownership. Passing a victim's private file_id returns the victim's content chunks. The fileIds come straight from the client (components/chat/chat-helpers/index.ts:65 sends raw file.id UUIDs), so a crafted POST controls them fully.
The untrusted input is the authenticated request body. The boundary crossed is user A reading user B's private file content (cross-user IDOR plus Supabase RLS bypass). Self-registration is the default Supabase auth posture, so the low-privilege account requirement is easily met.
PoC
Impact
An authenticated low-privilege user reads the indexed content of any other user's private uploaded files by supplying their file UUIDs, defeating the application's per-user/private-file isolation. Confidentiality breach across users (and tenants/workspaces) for all file content that has been embedded.
Remediation
Stop using the service-role client for this request. Use the request-scoped (cookie/JWT) Supabase client so RLS applies to the RPC; or make the RPCs SECURITY INVOKER, pass auth.uid(), and add AND user_id = auth.uid() (or the non-private files join) inside match_file_items_*; or, in the route, verify with the user-scoped client that the caller owns or has shared access to every id in uniqueFileIds and return 403 otherwise. Audit the other app/api/** routes that combine supabaseAdmin with unvalidated body ids (retrieval/process, assistants/openai) as a class.
Source: mckaywrigley/chatbot-ui