mem0: information disclosure
Summary
_build_session_scope() in mem0/memory/main.py builds the conversation-history scope key used to fetch/store the last N raw messages by naively joining caller-supplied user_id/agent_id/run_id values with unescaped & and = delimiters:
def _build_session_scope(filters):
parts = []
for key in sorted(["user_id", "agent_id", "run_id"]):
val = filters.get(key)
if val:
parts.append(f"{key}={val}")
return "&".join(parts)
_validate_and_trim_entity_id() (the only validation applied to these values) rejects only empty/whitespace-only strings , it never forbids & or =. This scope string is then used as an exact-match key against a SQLite column (storage.py: WHERE session_scope = ?) to fetch the last 10 raw messages for a given scope (main.py, Phase 0 of _add_to_vector_store, both sync and async).
Impact
Any caller who controls only their own run_id (or agent_id) , a very common integration shape, since apps frequently pass a client-/session-supplied identifier straight through as run_id without namespacing , can craft a value containing &user_id=<victim> (or &agent_id=<victim>) that produces a scope string byte-for-byte identical to a different tenant's real scope string.
Concretely: Tenant A calls add(msgs, user_id="alice", run_id="proj-x") → scope "run_id=proj-x&user_id=alice". An attacker who can only set run_id calls add(msgs,...
Affected versions
| Package | Affected | Fixed in |
|---|---|---|
| mem0ai PyPI | < 2.0.18 | 2.0.18 |
Details and references
Summary _build_session_scope() in mem0/memory/main.py builds the conversation-history scope key used to fetch/store the last N raw messages by naively joining caller-supplied user_id/agent_id/run_id values with unescaped & and = delimiters: def _build_session_scope(filters): parts = [] for key in sorted(["user_id", "agent_id", "run_id"]): val = filters.get(key) if val: parts.append(f"{key}={val}") return "&".join(parts) _validate_and_trim_entity_id() (the only validation applied to these values) rejects only empty/whitespace-only strings , it never forbids & or =. This scope string is then used as an exact-match key against a SQLite column (storage.py: WHERE session_scope = ?) to fetch the last 10 raw messages for a given scope (main.py, Phase 0 of _add_to_vector_store, both sync and async). Impact Any caller who controls only their own run_id (or agent_id) , a very common integration shape, since apps frequently pass a client-/session-supplied identifier straight through as run_id without namespacing , can craft a value containing &user_id=<victim> (or &agent_id=<victim>) that produces a scope string byte-for-byte identical to a different tenant's real scope string. Concretely: Tenant A calls add(msgs, user_id="alice", run_id="proj-x") → scope "run_id=proj-x&user_id=alice". An attacker who can only set run_id calls add(msgs, run_id="proj-x&user_id=alice") → identical scope string. The attacker's next add() call: 1. Reads Tenant A's raw prior conversation via get_last_messages(session_scope) and splices it verbatim into the attacker's own LLM fact-extraction prompt , a direct cross-tenant confidentiality leak (conversation content, potentially including PII, credentials, etc.). 2. Writes into the same colliding row-set via save_messages(), whose eviction logic (DELETE FROM messages WHERE session_scope = ? AND id NOT IN (...)) can evict/interleave Tenant A's history , permanent cross-tenant data corruption, not just a read. Reachable via the public Memory.add() / AsyncMemory.add() API, and by extension the REST server's POST /memories endpoint (which takes run_id/agent_id straight from the request body). Reproduction from mem0.memory.main import _build_session_scope # Tenant A: real user_id + run_id scope_a = _build_session_scope({"user_id": "alice", "run_id": "proj-x"}) print(scope_a) # "run_id=proj-x&user_id=alice" # Attacker: controls only run_id, embeds a fake user_id inside it scope_c = _build_session_scope({"run_id": "proj-x&user_id=alice"}) print(scope_c) # "run_id=proj-x&user_id=alice" assert scope_a == scope_c # byte-for-byte collision A full end-to-end proof (actual add() calls, no mocking of the vulnerable functions) confirms the attacker's subsequent add() call receives the victim's raw prior message content via get_last_messages. Suggested Fix Reject & and = in _validate_and_trim_entity_id(), alongside the existing whitespace check , this is the single validation chokepoint already used by every entry point (add, search, get_all, delete_all), so the fix is centralized. Alternatively/additionally, encode each component unambiguously (percent-encode each id, or use a length-prefixed/JSON-encoded scope key) so no valid-character combination in one field can ever collide with a different partition. Also affected: TypeScript SDK (npm mem0ai) The same flaw exists in mem0-ts/src/oss/src/memory/index.ts, Memory.buildSessionScope(), which joins agent_id/run_id/user_id into the same `key=value&key=value` string with the delimiters unescaped: private buildSessionScope(filters: SearchFilters): string { const parts: string[] = []; for (const key of ["agent_id", "run_id", "user_id"].sort()) { const val = (filters as any)[key]; if (val) parts.push(`${key}=${val}`); } return parts.join("&"); } The scope string is used the same way, as an exact-match key into the messages table via SQLiteManager.getLastMessages() and saveMessages(),
- CVSS 3.1
- CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N
- Severity from
- GitHub (reviewed advisory)
- Weakness
- CWE-200, CWE-863
- Also known as
- CVE-2026-116575
- github.com/mem0ai/mem0/security/advisories/GHSA-5324-3w58-qpp2
- nvd.nist.gov/vuln/detail/CVE-2026-116575
- github.com/mem0ai/mem0/pull/6892
- github.com/mem0ai/mem0/commit/71f2ebefa3494da21550fb525216818776cde67f
- github.com/mem0ai/mem0
- github.com/mem0ai/mem0/releases/tag/ts-v3.1.6
- github.com/mem0ai/mem0/releases/tag/v2.0.18