VYPR
Low severity3.7NVD Advisory· Published Apr 23, 2026· Updated May 1, 2026

CVE-2026-41354

CVE-2026-41354

Description

OpenClaw before 2026.4.2 contains an insufficient scope vulnerability in Zalo webhook replay dedupe keys that allows legitimate events from different conversations or senders to collide. Attackers can exploit weak deduplication scoping to cause silent message suppression and disrupt bot workflows across chat sessions.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.4.22026.4.2

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.4.2

Patches

1
ef7c553dd16e

fix(zalo): scope webhook replay dedupe (#58444)

https://github.com/openclaw/openclawAgustin RiveraApr 2, 2026via ghsa
3 files changed · +140 3
  • CHANGELOG.md+1 0 modified
    @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
     - Gateway: prune empty `node-pending-work` state entries after explicit acknowledgments and natural expiry so the per-node state map no longer grows indefinitely. (#58179) Thanks @gavyngong.
     - Browser/CDP: normalize trailing-dot localhost absolute-form hosts before loopback checks so remote CDP websocket URLs like `ws://localhost.:...` rewrite back to the configured remote host. (#59236) Thanks @mappel-nv.
     - Webhooks/secret comparison: replace ad-hoc timing-safe secret comparisons across BlueBubbles, Feishu, Mattermost, Telegram, Twilio, and Zalo webhook handlers with the shared `safeEqualSecret` helper and reject empty auth tokens in BlueBubbles. Thanks @eleqtrizit.
    +- Zalo/webhook replay: scope replay dedupe key by chat and sender so reused message IDs across different chats or senders no longer collide, and harden metadata reads for partially missing payloads. (#58444)
     
     ## 2026.4.1-beta.1
     
    
  • extensions/zalo/src/monitor.webhook.test.ts+127 2 modified
    @@ -187,7 +187,7 @@ describe("handleZaloWebhookRequest", () => {
         }
       });
     
    -  it("deduplicates webhook replay by event_name + message_id", async () => {
    +  it("deduplicates webhook replay for the same event origin", async () => {
         const sink = vi.fn();
         const unregister = registerTarget({ path: "/hook-replay", statusSink: sink });
         const payload = createTextUpdate({
    @@ -215,7 +215,6 @@ describe("handleZaloWebhookRequest", () => {
           unregister();
         }
       });
    -
       it("keeps replay dedupe isolated per authenticated target", async () => {
         const sinkA = vi.fn();
         const sinkB = vi.fn();
    @@ -272,6 +271,132 @@ describe("handleZaloWebhookRequest", () => {
         }
       });
     
    +  it("does not collide replay dedupe across different chats", async () => {
    +    const sink = vi.fn();
    +    const unregister = registerTarget({ path: "/hook-replay-chat-scope", statusSink: sink });
    +    const firstPayload = createTextUpdate({
    +      messageId: "msg-replay-chat-1",
    +      userId: "123",
    +      userName: "",
    +      chatId: "chat-a",
    +      text: "hello from a",
    +    });
    +    const secondPayload = createTextUpdate({
    +      messageId: "msg-replay-chat-1",
    +      userId: "123",
    +      userName: "",
    +      chatId: "chat-b",
    +      text: "hello from b",
    +    });
    +
    +    try {
    +      await withServer(webhookRequestHandler, async (baseUrl) => {
    +        const first = await fetch(`${baseUrl}/hook-replay-chat-scope`, {
    +          method: "POST",
    +          headers: {
    +            "x-bot-api-secret-token": "secret",
    +            "content-type": "application/json",
    +          },
    +          body: JSON.stringify(firstPayload),
    +        });
    +        const second = await fetch(`${baseUrl}/hook-replay-chat-scope`, {
    +          method: "POST",
    +          headers: {
    +            "x-bot-api-secret-token": "secret",
    +            "content-type": "application/json",
    +          },
    +          body: JSON.stringify(secondPayload),
    +        });
    +
    +        expect(first.status).toBe(200);
    +        expect(second.status).toBe(200);
    +      });
    +
    +      expect(sink).toHaveBeenCalledTimes(2);
    +    } finally {
    +      unregister();
    +    }
    +  });
    +
    +  it("does not collide replay dedupe across different senders in the same chat", async () => {
    +    const sink = vi.fn();
    +    const unregister = registerTarget({ path: "/hook-replay-sender-scope", statusSink: sink });
    +    const firstPayload = createTextUpdate({
    +      messageId: "msg-replay-sender-1",
    +      userId: "user-a",
    +      userName: "",
    +      chatId: "chat-shared",
    +      text: "hello from user a",
    +    });
    +    const secondPayload = createTextUpdate({
    +      messageId: "msg-replay-sender-1",
    +      userId: "user-b",
    +      userName: "",
    +      chatId: "chat-shared",
    +      text: "hello from user b",
    +    });
    +
    +    try {
    +      await withServer(webhookRequestHandler, async (baseUrl) => {
    +        const first = await fetch(`${baseUrl}/hook-replay-sender-scope`, {
    +          method: "POST",
    +          headers: {
    +            "x-bot-api-secret-token": "secret",
    +            "content-type": "application/json",
    +          },
    +          body: JSON.stringify(firstPayload),
    +        });
    +        const second = await fetch(`${baseUrl}/hook-replay-sender-scope`, {
    +          method: "POST",
    +          headers: {
    +            "x-bot-api-secret-token": "secret",
    +            "content-type": "application/json",
    +          },
    +          body: JSON.stringify(secondPayload),
    +        });
    +
    +        expect(first.status).toBe(200);
    +        expect(second.status).toBe(200);
    +      });
    +
    +      expect(sink).toHaveBeenCalledTimes(2);
    +    } finally {
    +      unregister();
    +    }
    +  });
    +
    +  it("does not throw when replay metadata is partially missing", async () => {
    +    const sink = vi.fn();
    +    const unregister = registerTarget({ path: "/hook-replay-partial", statusSink: sink });
    +    const payload = {
    +      event_name: "message.text.received",
    +      message: {
    +        message_id: "msg-replay-partial-1",
    +        date: Math.floor(Date.now() / 1000),
    +        text: "hello",
    +      },
    +    };
    +
    +    try {
    +      await withServer(webhookRequestHandler, async (baseUrl) => {
    +        const response = await fetch(`${baseUrl}/hook-replay-partial`, {
    +          method: "POST",
    +          headers: {
    +            "x-bot-api-secret-token": "secret",
    +            "content-type": "application/json",
    +          },
    +          body: JSON.stringify(payload),
    +        });
    +
    +        expect(response.status).toBe(200);
    +      });
    +
    +      expect(sink).toHaveBeenCalledTimes(1);
    +    } finally {
    +      unregister();
    +    }
    +  });
    +
       it("downloads inbound image media from webhook photo_url and preserves display_name", async () => {
         const {
           core,
    
  • extensions/zalo/src/monitor.webhook.ts+12 1 modified
    @@ -80,7 +80,18 @@ function isReplayEvent(target: ZaloWebhookTarget, update: ZaloUpdate, nowMs: num
       if (!messageId) {
         return false;
       }
    -  const key = `${target.path}:${target.account.accountId}:${update.event_name}:${messageId}`;
    +  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(":");
       return recentWebhookEvents.check(key, nowMs);
     }
     
    

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

5

News mentions

0

No linked articles in our index yet.