GHSA-5324-3W58-QPP2

Vulnerability from github – Published: 2026-10-28 21:17 – Updated: 2026-10-28 21:17
VLAI
Summary
Mem0: Cross-tenant conversation leak via unescaped delimiter collision in session-scope key (_build_session_scope)
Details

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)

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "mem0ai"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.0.18"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "npm",
        "name": "mem0ai"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.1.6"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-116575"
  ],
  "database_specific": {
    "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"
  },
  "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 \u0026 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 \"\u0026\".join(parts)\n\n_validate_and_trim_entity_id() (the only validation applied to these values) rejects only empty/whitespace-only strings \u2014 it never forbids \u0026 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) \u2014 a very common integration shape, since apps frequently pass a client-/session-supplied identifier straight through as run_id without namespacing \u2014 can craft a value containing \u0026user_id=\u003cvictim\u003e (or \u0026agent_id=\u003cvictim\u003e) that produces a scope string byte-for-byte identical to a different tenant\u0027s real scope string.\n\nConcretely: Tenant A calls add(msgs, user_id=\"alice\", run_id=\"proj-x\") \u2192 scope \"run_id=proj-x\u0026user_id=alice\". An attacker who can only set run_id calls add(msgs, run_id=\"proj-x\u0026user_id=alice\") \u2192 identical scope string. The attacker\u0027s next add() call:\n\n1. Reads Tenant A\u0027s raw prior conversation via get_last_messages(session_scope) and splices it verbatim into the attacker\u0027s own LLM fact-extraction prompt \u2014 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\u0027s history \u2014 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\u0027s 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\u0026user_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\u0026user_id=alice\"})\nprint(scope_c)  # \"run_id=proj-x\u0026user_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\u0027s subsequent add() call receives the victim\u0027s raw prior message content via get_last_messages.\n\nSuggested Fix\n\nReject \u0026 and = in _validate_and_trim_entity_id(), alongside the existing whitespace check \u2014 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\u0026key=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(\"\u0026\");\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)",
  "id": "GHSA-5324-3w58-qpp2",
  "modified": "2026-10-28T21:17:08Z",
  "published": "2026-10-28T21:17:08Z",
  "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"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Mem0: Cross-tenant conversation leak via unescaped delimiter collision in session-scope key (_build_session_scope)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.