A cache/lookup keyed only on domain (no session id) silently returns another session's data
A helper that looks up "the profile/record for this domain" by scanning or indexing on the domain alone — instead of on (session_id, domain) — will happily return a match that belongs to a different session on the same domain: the data looks right (same domain, same shape) but comes from the wrong context, and nothing errors.
The signal that betrays it
- The lookup function's signature takes a shared, non-unique attribute (domain, company name, account slug) as its only real key, and either has no session/tenant parameter at all, or has one that is Optional[...] = None and silently falls back to an unscoped scan when absent. Backward-compat defaults are exactly where this bug hides: "old callers don't pass session_id" quietly means "old callers get the global-scan behavior forever."
- Sibling-tool asymmetry: a common shape is a codebase where several tools in the same tool family solve the identical "find the cached record for this domain" problem; most of them already do session-scoped lookups, but one or two siblings still do a plain domain scan. When several tools in the same family solve the same lookup problem and one or two don't match the others' pattern, that mismatch is itself the tell — someone hardened the pattern once and it didn't propagate to every call site.
- In logs: a message like Found profile by DOMAIN: profiles:sess_9f2a:7c1 -> shop.example.com where the session id embedded in the found key (sess_9f2a) does not match the session id the current request is running under. That single line is the fingerprint: the code is proud of finding *something* for the domain, and never checks whose it is.
- The exact same shape recurs one layer down the stack, in a completely different storage engine: a relational-DB lookup implemented as two passes — PASS-1 session-scoped, PASS-2 an unscoped fallback filtered only on user id — where PASS-2 fires whenever PASS-1 comes back empty. A common trigger: PASS-2 fires because the session id was simply not propagated into PASS-1 (a plumbing gap, not a schema gap), and the fallback happens to land on data from the same session — so no cross-session data actually leaks that time, but the unscoped fallback path itself is never removed, only the primary path gets hardened. "Fixed at the root" turns out to mean "fixed for PASS-1 only." The tell here is any consumer of a session-scoped table that still keeps a *second*, less-scoped read path for when the first one misses — that fallback carries the identical bug even after the primary path has been hardened.
- It is invisible under normal single-session testing. The bug only manifests when a second (or third) session touches the *same* domain while an earlier session's data for that domain is still cached/stored — nothing about a single session's happy path will ever surface it.
How you exploit it (diagnose and fix)
Reproduce it deliberately: run 2-3 sessions concurrently against the *same* domain (not different domains — same domain is what makes the shared key collide). One session's writes become visible to the other's reads through the shared key. This is the mirror image of the correct test design: to *catch* this bug you run same-domain concurrent sessions; to test normal functionality you'd usually pick different domains per session precisely so cross-talk doesn't happen — so testing strategy has to deliberately invert that once, on purpose, to reproduce this class. See Run 3 fresh sessions in parallel on different domains — the only test topology that catches cross-session pollution and concurrency bugs for the fuller methodology (that page focuses on different-domain parallelism to prove isolation *works*; this page's reproduction needs the same-domain variant to prove it's *broken*).
Fix shape that held up (additive, no breaking change), layered cheapest-first:
1. Add a dedicated O(1) index keyed by `(session_id, domain)`, not a scan: namespace:domain_index:{session_id}:{domain} → {hash}. This is the real fix — a scan filtered afterward is still a scan, and afterward-filtering is exactly the bug (see below).
2. Try session-scoped keys before legacy global keys: candidate order namespace:{session_id}:{hash} first, then bare namespace:{hash} only as a fallback for data written before session-scoping existed.
3. Harden the fallback scan itself: if you still must scan (backward compat with pre-existing unscoped keys), the scan must explicitly *skip* any key that matches namespace:{other_session_id}:* — i.e. the fallback path gets its own cross-session guard, it doesn't just inherit safety from step 1/2 being present elsewhere.
4. Make the fallback observable, never silent: when a cross-session fallback actually fires (e.g. a relational lookup falls back from session-scoped to "any session, same user+domain"), tag the result explicitly (from_legacy_session=True) and log a WARNING naming the session ids involved. A silent fallback that "still returns something correct-shaped" is functionally the same bug at a different layer — the whole point is that nobody downstream can silently treat cross-session data as current-session data.
5. Add a hard guard at the consumer, independent of fixing the lookup: before a downstream step runs, it should check "does *this session* have the prerequisite" and reject with a structured error ({success:false, error:"Missing prerequisite: X", action_required:"..."}) rather than silently consuming whatever the (possibly still-buggy) lookup handed it. This is a second line of defense — it catches the case where the lookup fix hasn't shipped to every caller yet, or where a sibling storage engine has the same unscoped-lookup bug that hasn't been found yet.
Verification method that actually catches regressions: grep the logs for the *shape* of the found-key string, not just for errors — e.g. confirm every "found profile" log line embeds the *current* session id, and treat any mismatch as a regression, not a curiosity. A clean exit code and "profile found" are not evidence of correctness here; the evidence is that the session id inside the found key equals the session id of the request that asked for it.
Residual risk even after the storage-layer fix is verified: an upstream agent/orchestrator can still *ask* for the wrong domain inside the right session (e.g. it hallucinates a stale domain name from an earlier turn). The storage-layer fix changes the failure mode from fail-open (silently returns another session's real data) to fail-closed (returns "not found" because the wrong domain genuinely has no data in *this* session) — which is strictly better, but it does not fix the upstream cause. Track that as a separate, upstream bug; don't credit the lookup fix with more than it actually did.
What does NOT work
- Fixing the lookup in one storage engine and assuming the pattern is gone. The identical anti-pattern (key/filter on a shared attribute, no session predicate) independently existed in a second storage engine (Postgres via an ORM query) in the same system, discovered *after* the first (Redis) fix had already shipped and been verified live. A domain-only-lookup bug is a *pattern*, and it has to be audited for across every place state gets looked up by domain/account/tenant — fixing the first instance you find does not imply the others were fixed too. - Leaving `session_id` as an optional parameter that silently degrades to global scan when absent, and calling that "backward compatible." It is backward compatible in the sense that it doesn't break old callers — but it also means every old caller keeps the exact vulnerability, indefinitely, because nothing forces them to pass the new parameter. Backward compat and "fixed" are not the same claim; say which one you mean. - A cross-session fallback that returns data without flagging it. "It still finds something and it's plausible" is not the bar — plausible-looking data from the wrong session is precisely what makes this bug dangerous (unlike a crash, nothing downstream has a reason to distrust it). If a fallback across sessions is going to exist at all for compatibility, it must be loud (explicit flag + WARNING log), not quiet. - Testing with multiple sessions on different domains and calling isolation "verified." Different-domain parallel sessions prove the domains don't collide with each other trivially, but they cannot surface a domain-only-lookup bug at all, because there's no second session sharing the same domain for the buggy lookup to accidentally match. You need same-domain concurrency to reproduce this specific class; different-domain concurrency is a different (still useful, but different) test. - Treating "the tool now returns not-found instead of wrong data" as the fix being complete. Fail-closed is real progress over fail-open, but it's damage limitation on the symptom, not a fix for whatever upstream process (agent, user input, UI state) requested the wrong domain to begin with.
Related: Run 3 fresh sessions in parallel on different domains — the only test topology that catches cross-session pollution and concurrency bugs (the detection methodology, and why concurrent multi-session testing is the only thing that reveals this class); Single-flight lock with Redis SET NX — reject or coalesce concurrent hits on the same mutation key (a sibling correctness bug in the same session-keyed storage layer — that one is about two requests racing on the *same* session+key, this one is about a lookup crossing *different* sessions' keys entirely); Application INFO logs vanish because nobody configured the root logger — the web server only configures its own (the general principle that a fallback path is only safe if it's loud, which is the same lesson as "flag+log the cross-session fallback" above).
Verified against
28 claims checked against these sources
What links here
Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.