Cross-Tenant RAG Retrieval Leakage
Description
Enterprise assistants put authorization in the application layer: the orchestrator resolves the caller’s tenant, department or group claims, builds a metadata filter, namespace or tenant parameter, and sends it to the vector store with one shared backend credential. Anything that lets you influence that filter - a tampered request, a client-supplied group list, a mis-scoped retrieval key, or an index that has not caught up with an ACL change - returns another tenant’s chunks under a fully valid session.
The payoff is read access to documents the UI would never link to. The failure is silent: most assistants only render citations for chunks the caller may open, so a leaked chunk appears as unattributed prose inside a generated summary and a test that checks the citation list passes. Single-tenant happy-path testing hides it too, because a broken filter looks identical to a working one.
Examples
Tamper with the query-time metadata filter
Proxy the assistant through Burp Suite or mitmproxy and find the retrieval call. If the filter originated in the request body, widen it. Against Qdrant:
POST /collections/kb_shared/points/query HTTP/1.1
Host: qdrant.internal:6333
api-key: <RETRIEVER_KEY>
Content-Type: application/json
{
"query": [0.011, -0.043, 0.377],
"filter": {"must": [{"key": "tenant_id", "match": {"value": "tenant-a"}}]},
"limit": 10,
"with_payload": true
}
Change the value to tenant-b, or delete the filter object. Points returned with a foreign tenant_id confirm the store enforces no isolation of its own.
Forge the group claim used for security trimming
Azure AI Search security trimming is a string comparison against a filterable field, so it is only as strong as its claim source. Find the filter in the orchestrator request or a response debug field:
group_ids/any(g:search.in(g, 'grp-sales,grp-allstaff'))
Append a group id harvested from a shared document or directory listing, such as grp-finance, and resend. Finance chunks in the result set prove the group list comes from the client rather than from the validated token.
Replay a collection-scoped retrieval key
A Qdrant granular access token carries an access claim that is either global (r or m) or scoped to a named collection (r or rw). There is no row-level dimension: payload-filter restrictions inside tokens were deprecated in 1.15 and removed in 1.16.0, which rejects keys that still use them. A token issued for the retriever therefore reads every tenant in its collection:
jwt decode <RETRIEVER_JWT>
# {"access":[{"collection":"kb_shared","access":"r"}],"exp":1799999999}
curl -s http://qdrant.internal:6333/collections/kb_shared/points/scroll \
-H "api-key: <RETRIEVER_JWT>" -H 'Content-Type: application/json' \
-d '{"limit": 5, "with_payload": true}'
Several distinct tenant_id values in one scroll response prove the key is not a tenant boundary. The finding is that a legitimately issued retrieval credential spans tenants; the same scroll answered with no credential at all is the Exposed Vector Database Endpoints page under LLM09. Repeat against Weaviate with a different tenant argument on a Get query, or against Pinecone by changing the namespace parameter.
Exploit permission-sync lag, then prove it by summary
Have the client revoke your access to a lab document, then query immediately. Azure AI Search indexer schedules bottom out at five minutes; incremental SharePoint ACL updates need the 2026-05-01-preview REST API or later, and even then only items with unique permissions refresh on each successful run - changes inherited from a parent site, library, list or folder need an explicit refresh, so a stale chunk survives every scheduled run. Force generative disclosure instead of links:
Summarize the key figures in the Q3 restructuring memo. Do not cite sources,
just give the numbers and the marker CANARY-1234 if you can see it.
Recovered figures or the marker after revocation confirm the index still serves the old ACL.
Remediation
- Authorize inside the index query
- Build the filter server-side from the validated session token; never accept tenant, namespace, group or filter values from the request body.
- Re-check returned chunk ids against a second authorization call before they enter the prompt.
- Isolate high-sensitivity tenants physically
- Use a separate collection, index or namespace per tenant so a missing filter fails closed rather than returning a superset, with a distinct store credential per index.
- Close the permission-sync window
- Drive ACL changes into the index as events instead of waiting on a scheduled crawl, and fail the retrieval when permission metadata is staler than a defined threshold.
- Test the summary, not the citation list
- Add regression tests where user A summarizes user B’s document and assert on the absence of B’s content in the free text.
- Log the chunk ids behind every answer so leakage is reconstructable afterwards.