GHSA-5324-3w58-qpp2MediumCVSS 6.3

Mem0: Cross-tenant conversation leak via unescaped delimiter collision in session-scope key (_build_session_scope)

Published
October 28, 2026
Last Modified
October 28, 2026

🔗 CVE IDs covered (1)

📋 Description

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= (or &agent_id=) 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(), so the identical forge, cross-tenant read and eviction all apply.

Verified against the current releases as of 2026-07-26: pip mem0ai v2.0.14 and npm mem0ai ts-v3.1.2 both still contain the unescaped join.

Reporter: Abhinav Singh (GitHub: OfficialAbhinavSingh)

🎯 Affected products2

  • pip/mem0ai:< 2.0.18
  • npm/mem0ai:< 3.1.6

🔗 References (7)