fix(zalo): scope replay dedupe cache key to path and account [AI] (#59387)

* fix: address issue #139

* changelog: add zalo replay dedupe fix entry

---------

Co-authored-by: Jacob Tomlinson <jtomlinson@nvidia.com>
This commit is contained in:
pgondhi987
2026-04-02 22:06:35 +05:30
committed by GitHub
parent d5b6bfc48c
commit 7cea7c2970
3 changed files with 126 additions and 12 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
- Agents/tools: include value-shape hints in missing-parameter tool errors so dropped, empty-string, and wrong-type write payloads are easier to diagnose from logs. (#55317) Thanks @priyansh19.
- Plugins/browser: block SSRF redirect bypass by installing a real-time Playwright route handler before `page.goto()` so navigation to private/internal IPs is intercepted and aborted mid-redirect instead of checked post-hoc. (#58771) Thanks @pgondhi987.
- Android/assistant: keep queued App Actions prompts pending when auto-send enqueue is rejected, so transient chat-health drops do not silently lose the assistant request. Thanks @obviyus.
- Zalo/webhook: scope replay-dedupe cache key to path and account using `JSON.stringify` so multi-account deployments do not silently drop events due to cross-account cache poisoning. (#59387) Thanks @pgondhi987.
## 2026.4.2-beta.1

View File

@@ -397,6 +397,120 @@ describe("handleZaloWebhookRequest", () => {
}
});
it("keeps replay dedupe isolated when path/account values collide under colon-joined keys", async () => {
const sinkA = vi.fn();
const sinkB = vi.fn();
// Old key format `${path}:${accountId}:${event_name}:${messageId}` would collide for these two targets.
const unregisterA = registerTarget({
path: "/hook-replay-collision:a",
secret: "secret-a",
statusSink: sinkA,
account: {
...DEFAULT_ACCOUNT,
accountId: "team",
},
});
const unregisterB = registerTarget({
path: "/hook-replay-collision",
secret: "secret-b",
statusSink: sinkB,
account: {
...DEFAULT_ACCOUNT,
accountId: "a:team",
},
});
const payload = createTextUpdate({
messageId: "msg-replay-collision-1",
userId: "123",
userName: "",
chatId: "123",
text: "hello",
});
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const first = await fetch(`${baseUrl}/hook-replay-collision:a`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret-a",
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
const second = await fetch(`${baseUrl}/hook-replay-collision`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret-b",
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
expect(first.status).toBe(200);
expect(second.status).toBe(200);
});
expect(sinkA).toHaveBeenCalledTimes(1);
expect(sinkB).toHaveBeenCalledTimes(1);
} finally {
unregisterA();
unregisterB();
}
});
it("keeps replay dedupe isolated across different webhook paths", async () => {
const sinkA = vi.fn();
const sinkB = vi.fn();
const sharedSecret = "secret";
const unregisterA = registerTarget({
path: "/hook-replay-scope-a",
secret: sharedSecret,
statusSink: sinkA,
});
const unregisterB = registerTarget({
path: "/hook-replay-scope-b",
secret: sharedSecret,
statusSink: sinkB,
});
const payload = createTextUpdate({
messageId: "msg-replay-cross-path-1",
userId: "123",
userName: "",
chatId: "123",
text: "hello",
});
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
const first = await fetch(`${baseUrl}/hook-replay-scope-a`, {
method: "POST",
headers: {
"x-bot-api-secret-token": sharedSecret,
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
const second = await fetch(`${baseUrl}/hook-replay-scope-b`, {
method: "POST",
headers: {
"x-bot-api-secret-token": sharedSecret,
"content-type": "application/json",
},
body: JSON.stringify(payload),
});
expect(first.status).toBe(200);
expect(second.status).toBe(200);
});
expect(sinkA).toHaveBeenCalledTimes(1);
expect(sinkB).toHaveBeenCalledTimes(1);
} finally {
unregisterA();
unregisterB();
}
});
it("downloads inbound image media from webhook photo_url and preserves display_name", async () => {
const {
core,

View File

@@ -75,23 +75,22 @@ function timingSafeEquals(left: string, right: string): boolean {
return safeEqualSecret(left, right);
}
function buildReplayEventCacheKey(
target: ZaloWebhookTarget,
update: ZaloUpdate,
messageId: string,
): string {
const chatId = update.message?.chat?.id ?? "";
const senderId = update.message?.from?.id ?? "";
return JSON.stringify([target.path, target.account.accountId, update.event_name, chatId, senderId, messageId]);
}
function isReplayEvent(target: ZaloWebhookTarget, update: ZaloUpdate, nowMs: number): boolean {
const messageId = update.message?.message_id;
if (!messageId) {
return false;
}
const chatId = update.message?.chat?.id ?? "";
const senderId = update.message?.from?.id ?? "";
// Scope replay dedupe to the authenticated target and the message origin so
// reused message ids in other chats or from other senders do not collide.
const key = [
target.path,
target.account.accountId,
update.event_name,
chatId,
senderId,
messageId,
].join(":");
const key = buildReplayEventCacheKey(target, update, messageId);
return recentWebhookEvents.check(key, nowMs);
}