{"id":"GHSA-5324-3w58-qpp2","summary":"Mem0: Cross-tenant conversation leak via unescaped delimiter collision in session-scope key (_build_session_scope)","details":"Summary\n\n_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:\n\ndef _build_session_scope(filters):\n    parts = []\n    for key in sorted([\"user_id\", \"agent_id\", \"run_id\"]):\n        val = filters.get(key)\n        if val:\n            parts.append(f\"{key}={val}\")\n    return \"&\".join(parts)\n\n_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).\n\nImpact\n\nAny 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=\u003cvictim\u003e (or &agent_id=\u003cvictim\u003e) that produces a scope string byte-for-byte identical to a different tenant's real scope string.\n\nConcretely: 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:\n\n1. 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.).\n2. 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.\n\nReachable 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).\n\nReproduction\n\nfrom mem0.memory.main import _build_session_scope\n\n# Tenant A: real user_id + run_id\nscope_a = _build_session_scope({\"user_id\": \"alice\", \"run_id\": \"proj-x\"})\nprint(scope_a)  # \"run_id=proj-x&user_id=alice\"\n\n# Attacker: controls only run_id, embeds a fake user_id inside it\nscope_c = _build_session_scope({\"run_id\": \"proj-x&user_id=alice\"})\nprint(scope_c)  # \"run_id=proj-x&user_id=alice\"\n\nassert scope_a == scope_c  # byte-for-byte collision\n\nA 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.\n\nSuggested Fix\n\nReject & 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.\n\nAlso affected: TypeScript SDK (npm mem0ai)\n\nThe same flaw exists in mem0-ts/src/oss/src/memory/index.ts,\nMemory.buildSessionScope(), which joins agent_id/run_id/user_id into the same\n`key=value&key=value` string with the delimiters unescaped:\n\n  private buildSessionScope(filters: SearchFilters): string {\n    const parts: string[] = [];\n    for (const key of [\"agent_id\", \"run_id\", \"user_id\"].sort()) {\n      const val = (filters as any)[key];\n      if (val) parts.push(`${key}=${val}`);\n    }\n    return parts.join(\"&\");\n  }\n\nThe scope string is used the same way, as an exact-match key into the messages\ntable via SQLiteManager.getLastMessages() and saveMessages(), so the identical\nforge, cross-tenant read and eviction all apply.\n\nVerified against the current releases as of 2026-07-26: pip mem0ai v2.0.14 and\nnpm mem0ai ts-v3.1.2 both still contain the unescaped join.\n\nReporter: Abhinav Singh (GitHub: OfficialAbhinavSingh)","aliases":["CVE-2026-116575"],"modified":"2026-10-28T21:30:05.847356896Z","published":"2026-10-28T21:17:08Z","database_specific":{"cwe_ids":["CWE-200","CWE-863"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-10-28T21:17:08Z","nvd_published_at":"2026-10-26T21:18:13Z"},"references":[{"type":"WEB","url":"https://github.com/mem0ai/mem0/security/advisories/GHSA-5324-3w58-qpp2"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-116575"},{"type":"WEB","url":"https://github.com/mem0ai/mem0/pull/6892"},{"type":"WEB","url":"https://github.com/mem0ai/mem0/commit/71f2ebefa3494da21550fb525216818776cde67f"},{"type":"PACKAGE","url":"https://github.com/mem0ai/mem0"},{"type":"WEB","url":"https://github.com/mem0ai/mem0/releases/tag/ts-v3.1.6"},{"type":"WEB","url":"https://github.com/mem0ai/mem0/releases/tag/v2.0.18"}],"affected":[{"package":{"name":"mem0ai","ecosystem":"PyPI","purl":"pkg:pypi/mem0ai"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"2.0.18"}]}],"versions":["0.0.1","0.0.10","0.0.11","0.0.12","0.0.13","0.0.14","0.0.15","0.0.16","0.0.17","0.0.18","0.0.19","0.0.2","0.0.20","0.0.20rc1","0.0.21","0.0.3","0.0.4","0.0.5","0.0.6","0.0.7","0.0.8","0.0.9","0.1.0","0.1.1","0.1.10","0.1.100","0.1.101","0.1.102","0.1.103","0.1.104","0.1.106","0.1.107","0.1.107rc1","0.1.107rc2","0.1.108","0.1.109","0.1.11","0.1.110","0.1.111","0.1.112","0.1.113","0.1.114","0.1.115","0.1.116","0.1.117","0.1.118","0.1.12","0.1.13","0.1.14","0.1.15","0.1.16","0.1.17","0.1.18","0.1.19","0.1.2","0.1.20","0.1.20a0","0.1.21","0.1.22","0.1.23","0.1.24","0.1.25","0.1.26","0.1.27","0.1.28","0.1.29","0.1.3","0.1.30","0.1.31","0.1.32","0.1.33","0.1.34","0.1.35","0.1.36","0.1.37","0.1.38","0.1.39","0.1.4","0.1.40","0.1.41","0.1.42","0.1.43","0.1.44","0.1.45","0.1.45rc1","0.1.46","0.1.47","0.1.48","0.1.49","0.1.5","0.1.50","0.1.52","0.1.53","0.1.54","0.1.55","0.1.56","0.1.57","0.1.58","0.1.59","0.1.6","0.1.60","0.1.61","0.1.62","0.1.63","0.1.65","0.1.66","0.1.67","0.1.69","0.1.7","0.1.70","0.1.71","0.1.72","0.1.73","0.1.74","0.1.75","0.1.76","0.1.77","0.1.78","0.1.78a0","0.1.78a1","0.1.78a2","0.1.79","0.1.8","0.1.80","0.1.81","0.1.81a0","0.1.81a1","0.1.81a2","0.1.82","0.1.83","0.1.84","0.1.84a1","0.1.85","0.1.86","0.1.87","0.1.88","0.1.89","0.1.9","0.1.90","0.1.91","0.1.92","0.1.93","0.1.94","0.1.95","0.1.96","0.1.96rc1","0.1.97","0.1.98","0.1.99","0.1.99a0","0.1.99a1","0.1.99a2","0.1.99a3","1.0.0","1.0.0b0","1.0.1","1.0.10","1.0.11","1.0.2","1.0.3","1.0.4","1.0.5","1.0.6","1.0.7","1.0.8","1.0.9","2.0.0","2.0.0b0","2.0.0b1","2.0.0b2","2.0.1","2.0.10","2.0.11","2.0.12","2.0.13","2.0.14","2.0.15","2.0.16","2.0.17","2.0.2","2.0.3","2.0.4","2.0.5","2.0.6","2.0.7","2.0.8"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-5324-3w58-qpp2/GHSA-5324-3w58-qpp2.json"}},{"package":{"name":"mem0ai","ecosystem":"npm","purl":"pkg:npm/mem0ai"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.1.6"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-5324-3w58-qpp2/GHSA-5324-3w58-qpp2.json"}}],"schema_version":"1.9.2","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N"}]}