Medium severity4.3NVD Advisory· Published Apr 28, 2026· Updated Apr 28, 2026
CVE-2026-41362
CVE-2026-41362
Description
OpenClaw versions 2026.2.19 before 2026.3.31 contain an improper cache isolation vulnerability in the Zalo webhook replay-dedupe mechanism that is shared across authenticated webhook targets. Attackers controlling one authenticated Zalo webhook path in multi-account deployments can suppress legitimate events on different accounts by matching event_name and message_id parameters.
Affected products
1Patches
27cea7c29705bfix(zalo): scope replay dedupe cache key to path and account [AI] (#59387)
3 files changed · +126 −12
CHANGELOG.md+1 −0 modified@@ -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
extensions/zalo/src/monitor.webhook.test.ts+114 −0 modified@@ -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,
extensions/zalo/src/monitor.webhook.ts+11 −12 modified@@ -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); }
4d038bb242c1fix(zalo): scope webhook replay dedupe per target (#58196)
3 files changed · +61 −3
CHANGELOG.md+1 −0 modified@@ -130,6 +130,7 @@ Docs: https://docs.openclaw.ai - Diffs/config: preserve schema-shaped plugin config parsing from `diffsPluginConfigSchema.safeParse()`, so direct callers keep `defaults` and `security` sections instead of receiving flattened tool defaults. (#57904) Thanks @gumadeiras. - Diffs: fall back to plain text when `lang` hints are invalid during diff render and viewer hydration, so bad or stale language values no longer break the diff viewer. (#57902) Thanks @gumadeiras. - Doctor/plugins: skip false Matrix legacy-helper warnings when no migration plans exist, and keep bundled `enabledByDefault` plugins in the gateway startup set. (#57931) Thanks @dinakars777. +- Zalo/webhooks: scope replay dedupe to the authenticated target so one configured account can no longer cause same-id inbound events for another target to be dropped. Thanks @smaeljaish771 and @vincentkoc. - Matrix/CLI send: start one-off Matrix send clients before outbound delivery so `openclaw message send --channel matrix` restores E2EE in encrypted rooms instead of sending plain events. (#57936) Thanks @gumadeiras. - xAI/Responses: normalize image-bearing tool results for xAI responses payloads, including OpenResponses-style `input_image.source` parts, so image tool replays no longer 422 on the follow-up turn. (#58017) Thanks @neeravmakwana. - Cron/isolated sessions: carry the full live-session provider, model, and auth-profile selection across retry restarts so cron jobs with model overrides no longer fail or loop on mid-run model-switch requests. (#57972) Thanks @issaba1.
extensions/zalo/src/monitor.webhook.test.ts+56 −0 modified@@ -238,6 +238,62 @@ describe("handleZaloWebhookRequest", () => { } }); + it("keeps replay dedupe isolated per authenticated target", async () => { + const sinkA = vi.fn(); + const sinkB = vi.fn(); + const unregisterA = registerTarget({ + path: "/hook-replay-scope", + secret: "secret-a", + statusSink: sinkA, + }); + const unregisterB = registerTarget({ + path: "/hook-replay-scope", + secret: "secret-b", + statusSink: sinkB, + account: { + ...DEFAULT_ACCOUNT, + accountId: "work", + }, + }); + const payload = createTextUpdate({ + messageId: "msg-replay-scope-1", + userId: "123", + userName: "", + chatId: "123", + text: "hello", + }); + + try { + await withServer(webhookRequestHandler, async (baseUrl) => { + const first = await fetch(`${baseUrl}/hook-replay-scope`, { + 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-scope`, { + 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("downloads inbound image media from webhook photo_url and preserves display_name", async () => { const { core,
extensions/zalo/src/monitor.webhook.ts+4 −3 modified@@ -59,6 +59,7 @@ const webhookAnomalyTracker = createWebhookAnomalyTracker({ export function clearZaloWebhookSecurityStateForTest(): void { webhookRateLimiter.clear(); + recentWebhookEvents.clear(); webhookAnomalyTracker.clear(); } @@ -87,12 +88,12 @@ function timingSafeEquals(left: string, right: string): boolean { return timingSafeEqual(leftBuffer, rightBuffer); } -function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean { +function isReplayEvent(target: ZaloWebhookTarget, update: ZaloUpdate, nowMs: number): boolean { const messageId = update.message?.message_id; if (!messageId) { return false; } - const key = `${update.event_name}:${messageId}`; + const key = `${target.path}:${target.account.accountId}:${update.event_name}:${messageId}`; return recentWebhookEvents.check(key, nowMs); } @@ -222,7 +223,7 @@ export async function handleZaloWebhookRequest( return true; } - if (isReplayEvent(update, nowMs)) { + if (isReplayEvent(target, update, nowMs)) { res.statusCode = 200; res.end("ok"); return true;
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/openclaw/openclaw/commit/4d038bb242c11f39e45f6a4bde400e5fd42e4ebfnvdPatch
- github.com/openclaw/openclaw/commit/7cea7c29705b188b464cc9cdc107c275b94b2a72nvdPatch
- github.com/openclaw/openclaw/security/advisories/GHSA-fqrj-m88p-qf3vnvdVendor Advisory
- www.vulncheck.com/advisories/openclaw-webhook-replay-dedupe-cache-event-suppression-via-shared-authenticationnvdThird Party Advisory
News mentions
0No linked articles in our index yet.