diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index 4e48dc181..5ad72dd0c 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -101,6 +101,10 @@ const ENTITY_PARAMS = [ "runId", ]; +// Identity keys stripped from update() metadata: ENTITY_PARAMS covers user_id/agent_id/run_id +// in both casings (the default store promotes camelCase on read); actor_id has no camelCase alias. +const IDENTITY_KEYS = [...ENTITY_PARAMS, "actor_id"]; + /** * Validates that no top-level entity parameters are passed in config. * @throws Error if entity params are found at top level @@ -1941,9 +1945,14 @@ export class Memory { existingEmbeddings[newData] || (await this.embedder.embed(newData, "update")); + // Caller metadata must not overwrite or inject an identity scope (#6342 / #6367). + const sanitizedMetadata = Object.fromEntries( + Object.entries(metadata).filter(([k]) => !IDENTITY_KEYS.includes(k)), + ); + const newMetadata = { ...existingMemory.payload, - ...metadata, + ...sanitizedMetadata, data: newData, hash: createHash("md5").update(newData).digest("hex"), textLemmatized: lemmatizeForBm25(newData), diff --git a/mem0-ts/src/oss/tests/memory.crud.test.ts b/mem0-ts/src/oss/tests/memory.crud.test.ts index de2565c16..d33776730 100644 --- a/mem0-ts/src/oss/tests/memory.crud.test.ts +++ b/mem0-ts/src/oss/tests/memory.crud.test.ts @@ -207,6 +207,79 @@ describe("Memory - update()", () => { expect.objectContaining({ category: "hobbies", priority: "high" }), ); }); + + // Regression: metadata passed to update() must never overwrite a memory's + // identity fields (issues #6277 / #6278). + test("metadata cannot overwrite identity fields (tenant isolation)", async () => { + const tenantA = `tenant_a_${Date.now()}`; + const tenantB = `tenant_b_${Date.now()}`; + const addResult: SearchResult = await memory.add("Tenant A secret", { + userId: tenantA, + infer: false, + }); + const id = addResult.results[0].id; + + // Caller metadata attempts to re-scope the memory to tenant B. + await memory.update(id, { + text: "Updated text", + metadata: { user_id: tenantB, agent_id: "attacker", run_id: "attacker" }, + }); + + // The memory must remain in tenant A's scope... + const aList: SearchResult = await memory.getAll({ + filters: { user_id: tenantA }, + }); + expect(aList.results.map((m) => m.id)).toContain(id); + + // ...and must never leak into tenant B's scope. + const bList: SearchResult = await memory.getAll({ + filters: { user_id: tenantB }, + }); + expect(bList.results.map((m) => m.id)).not.toContain(id); + }); + + // A memory created with only some identity fields (e.g. run_id only) must not + // let update() metadata inject a brand-new identity key. The default vector + // store promotes camelCase aliases to snake_case on read, so both casings must be covered. + test("metadata cannot inject an identity field on update (snake_case or camelCase)", async () => { + const runId = `run_only_${Date.now()}`; + const attacker = `attacker_${Date.now()}`; + const addResult: SearchResult = await memory.add("Run-scoped secret", { + runId, + infer: false, + }); + const id = addResult.results[0].id; + + // Memory has no user_id/agent_id/actor_id — metadata tries to inject them, + // in both snake_case and camelCase. + await memory.update(id, { + text: "Updated text", + metadata: { + user_id: attacker, + agent_id: attacker, + actor_id: attacker, + userId: attacker, + agentId: attacker, + }, + }); + + // Still reachable under its original run_id scope... + const runList: SearchResult = await memory.getAll({ + filters: { run_id: runId }, + }); + expect(runList.results.map((m) => m.id)).toContain(id); + + // ...and the injected identity scopes must not resolve to it. + const userList: SearchResult = await memory.getAll({ + filters: { user_id: attacker }, + }); + expect(userList.results.map((m) => m.id)).not.toContain(id); + + const agentList: SearchResult = await memory.getAll({ + filters: { agent_id: attacker }, + }); + expect(agentList.results.map((m) => m.id)).not.toContain(id); + }); }); // ─── update() options: text / data / metadata / expirationDate ───