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:
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
scope_a = _build_session_scope({"user_id": "alice", "run_id": "proj-x"}) print(scope_a) # "run_id=proj-x&user_id=alice"
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)
{
"cwe_ids": [
"CWE-200",
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-28T21:17:08Z",
"nvd_published_at": "2026-10-26T21:18:13Z",
"severity": "MODERATE"
}