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:
@@ -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),
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
Reference in New Issue
Block a user