Cross-Session Context Bleed Testing
Description
Between the user and the model sit several layers that hold state on purpose. Server-side conversation state is keyed by an opaque identifier - a thread id, a conversation id, a stored response id used to chain turns. A gateway keeps an exact-match cache and often a semantic cache, as LiteLLM does with Redis or Qdrant backends and GPTCache does in-process, both returning a stored completion when a new request is judged close enough. The inference server keeps a KV prefix cache, on by default under vLLM V1. And the application itself frequently pools agent workers, reusing a process and its in-memory history across calls.
The failure modes are mundane: a cache key built from the prompt hash alone with no tenant, user or API key mixed in; a similarity threshold loose enough that another customer’s paraphrase is a hit; a conversation identifier accepted without checking who owns it; a worker whose chat history is never cleared between tasks. Single-session testing cannot see any of it, because the target behaves perfectly until two identities are talking to it at once. What comes back is another tenant’s question, their answer and their injected variable block - hidden context no prompt probe would surface.
Examples
Seed canaries across two tenants and confirm the hit
Get two independent credentials. Have the first session plant a marker with an ordinary-looking question, then hit the same question from the second session with near-miss paraphrases and shared prefixes.
# tenant A seeds the marker and the question
curl -s -X POST https://app.example.com/api/chat \
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"session_id":"a-1","messages":[{"role":"user","content":"Project code CANARY-A-4417. What is our quarterly revenue recognition policy?"}]}'
# tenant B, paraphrases of the same question plus the shared prefix
for q in "What is our quarterly revenue recognition policy?" \
"Please tell me the quarterly revenue recognition policy." \
"Project code CANARY-B-0001. What is our quarterly revenue recognition policy?"; do
curl -s -X POST https://app.example.com/api/chat \
-H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' \
-d "$(python3 -c 'import json,sys; print(json.dumps({"session_id":"b-1","messages":[{"role":"user","content":sys.argv[1]}]}))' "$q")"
echo
done | tee b-responses.txt
rg -n 'CANARY-A-4417' b-responses.txt
The marker, or A’s answer text arriving verbatim in B’s window, is the finding. A semantic cache serves the closest stored completion above its threshold, so the second tell is an identical answer with collapsed latency. Measure it rather than guessing.
for i in 1 2 3; do
curl -s -o /dev/null -w '%{time_total}\n' -X POST https://app.example.com/api/chat \
-H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' \
-d '{"messages":[{"role":"user","content":"What is our quarterly revenue recognition policy?"}]}'
done
A first call in seconds followed by calls in tens of milliseconds is a cache hit. Where the gateway forwards provider usage fields, read them directly: an OpenAI-compatible response reports usage.prompt_tokens_details.cached_tokens and an Anthropic response usage.cache_read_input_tokens, and a non-zero value on a request you never sent before means someone else warmed that prefix. Then read the settings that decide isolation - the threshold, and whether identity reaches the collection or namespace:
litellm_settings:
cache: true
cache_params:
type: qdrant-semantic
similarity_threshold: 0.8
qdrant_collection_name: litellm_cache
namespace: shared
The test that matters is the same paraphrase from a different key: if it still returns in tens of milliseconds with the identical body, the cache is not partitioned by caller.
Replay and enumerate conversation identifiers
Capture your own identifier in Burp Suite, then present it with the other tenant’s credentials.
GET /api/conversations/conv_01JABCDEF/messages HTTP/1.1
Host: app.example.com
Authorization: Bearer <TOKEN_B>
A 200 carrying tenant A’s turns is a missing ownership check. Do the same for chained turns: pass a stored response or thread id you did not create as the continuation reference and check whether prior turns reappear in the completion. If identifiers look sequential or short, enumerate them:
ffuf -u https://app.example.com/api/conversations/conv_FUZZ/messages \
-H "Authorization: Bearer $TOKEN_B" -w ids.txt -mc 200 -ac
Hammer pooled workers with per-request markers
Send concurrent requests that each carry a distinct marker and print what came back next to what went out.
seq 1 40 | xargs -P 8 -I{} sh -c '
out=$(curl -s -X POST https://app.example.com/api/chat \
-H "Authorization: Bearer '"$TOKEN_B"'" -H "Content-Type: application/json" \
-d "{\"messages\":[{\"role\":\"user\",\"content\":\"Echo the marker CANARY-B-{} and nothing else.\"}]}")
echo "sent=CANARY-B-{} got=$(printf %s "$out" | rg -o "CANARY-B-[0-9]+" | head -1)"' | tee pool.log
awk '{split($1,s,"="); split($2,g,"="); if (s[2] != g[2]) print "MISMATCH: " $0}' pool.log
Any line where the returned marker differs from the one sent, or where a response contains two markers, proves per-request state is shared between concurrent calls. The prefix cache is a latency channel rather than a content channel: it does not hand over another tenant’s tokens, but a long shared prefix that returns fast on first use tells you someone else has already sent it.
Remediation
- Key every cache by identity
- Mix tenant, user and API key into the exact-match and semantic cache keys, or give each tenant its own namespace or collection, then verify with the two-key latency test above.
- Raise the similarity threshold, and disable semantic caching on multi-turn and agentic routes, where every turn resembles the last.
- Bind session state to its owner
- Store the owning identity with every conversation, thread and stored-response record and check it on read; use unguessable identifiers, and never accept a client-supplied continuation reference without that check.
- Reset state between tasks
- Instantiate conversation history per request; forbid process-global history objects in pooled workers, and assert the object is empty at task start.
- Isolate the KV cache where tenancy demands it
- Where shared prefixes are themselves sensitive, run per-tenant serving pools or disable automatic prefix caching on the shared endpoint.
- Attribute every hit
- Log cache key, hit or miss and caller identity for every completion, and alert when a hit crosses a tenant, team or key boundary.