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:
co-authored by
kartik-mem0
parent
b05cce581b
commit
d0c23a5950
@@ -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