fix(ts-oss): don't let update() metadata overwrite user_id/agent_id/run_id (#6343)

Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
Abhinav Singh
2026-07-21 12:11:47 +05:30
committed by GitHub
parent b05cce581b
commit d0c23a5950
2 changed files with 83 additions and 1 deletions
+10 -1
View File
@@ -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),
+73
View File
@@ -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 ───