VYPR
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

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: >=2026.2.19,<2026.3.31

Patches

2
7cea7c29705b

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

https://github.com/openclaw/openclawpgondhi987Apr 2, 2026via nvd-ref
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);
     }
     
    
4d038bb242c1

fix(zalo): scope webhook replay dedupe per target (#58196)

https://github.com/openclaw/openclawVincent KocMar 31, 2026via nvd-ref
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

News mentions

0

No linked articles in our index yet.