From 46783d41e92fe43e27eb52041d11fdff80892811 Mon Sep 17 00:00:00 2001 From: hcl Date: Wed, 29 Apr 2026 09:09:23 +0800 Subject: [PATCH] fix(whatsapp): gate pairing access-control on extractable inbound user content (#73797) (#73823) Merged via squash. Prepared head SHA: 61506e1439677ecc45854abc2b933e080468e966 Co-authored-by: hclsys <7755017+hclsys@users.noreply.github.com> Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com> Reviewed-by: @mcaxtr --- CHANGELOG.md | 1 + .../whatsapp/src/inbound/extract.test.ts | 181 +++++++++++++++++- extensions/whatsapp/src/inbound/extract.ts | 46 +++++ extensions/whatsapp/src/inbound/monitor.ts | 13 ++ 4 files changed, 240 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7457b1b954e..7d626142ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai - Auto-reply/session: carry the tail of user/assistant turns into the freshly-rotated transcript on silent in-reply session resets (compaction failure, role-ordering conflict) so direct-chat continuity survives the rebind. Fixes #70853. (#70898) Thanks @neeravmakwana. - Config: skip malformed non-string `env.vars` entries before env-reference checks, so config loading no longer crashes on JSON values like numbers or booleans. (#42402) Thanks @MiltonHeYan. - Docker Compose: default missing config and workspace bind mounts to `${HOME:-/tmp}/.openclaw` so manual compose runs do not create invalid empty-source volume specs. (#64485) Thanks @jlapenna. +- Channels/WhatsApp: restrict pairing verification replies to real inbound user content, preventing unsolicited prompts from receipts, typing indicators, presence updates, and other non-message Baileys upserts. Fixes #73797. (#73823) Thanks @hclsys. ## 2026.4.27 diff --git a/extensions/whatsapp/src/inbound/extract.test.ts b/extensions/whatsapp/src/inbound/extract.test.ts index 62e1971aee6..0414c11621e 100644 --- a/extensions/whatsapp/src/inbound/extract.test.ts +++ b/extensions/whatsapp/src/inbound/extract.test.ts @@ -1,6 +1,6 @@ import type { proto } from "@whiskeysockets/baileys"; import { describe, expect, it } from "vitest"; -import { extractMentionedJids } from "./extract.js"; +import { extractMentionedJids, hasInboundUserContent } from "./extract.js"; describe("extractMentionedJids", () => { const botJid = "5511999999999@s.whatsapp.net"; @@ -101,3 +101,182 @@ describe("extractMentionedJids", () => { expect(extractMentionedJids(message)).toEqual([botJid]); }); }); + +describe("hasInboundUserContent", () => { + it("returns true for plain text conversation", () => { + expect(hasInboundUserContent({ conversation: "hello" })).toBe(true); + }); + + it("returns true for extendedTextMessage", () => { + expect( + hasInboundUserContent({ extendedTextMessage: { text: "hello" } } as proto.IMessage), + ).toBe(true); + }); + + it("returns true for image message", () => { + expect( + hasInboundUserContent({ imageMessage: { mimetype: "image/png" } } as proto.IMessage), + ).toBe(true); + }); + + it("returns true for video message", () => { + expect( + hasInboundUserContent({ videoMessage: { mimetype: "video/mp4" } } as proto.IMessage), + ).toBe(true); + }); + + it("returns true for audio message", () => { + expect( + hasInboundUserContent({ audioMessage: { mimetype: "audio/ogg" } } as proto.IMessage), + ).toBe(true); + }); + + it("returns true for document message", () => { + expect( + hasInboundUserContent({ + documentMessage: { fileName: "x.pdf" }, + } as proto.IMessage), + ).toBe(true); + }); + + it("returns true for sticker message", () => { + expect( + hasInboundUserContent({ stickerMessage: { mimetype: "image/webp" } } as proto.IMessage), + ).toBe(true); + }); + + it("returns true for location message with valid coords", () => { + expect( + hasInboundUserContent({ + locationMessage: { degreesLatitude: 1, degreesLongitude: 2 }, + } as proto.IMessage), + ).toBe(true); + }); + + it("returns true for live location message with valid coords", () => { + expect( + hasInboundUserContent({ + liveLocationMessage: { degreesLatitude: 1, degreesLongitude: 2 }, + } as proto.IMessage), + ).toBe(true); + }); + + it("returns true for contact message", () => { + expect( + hasInboundUserContent({ + contactMessage: { displayName: "Alice", vcard: "BEGIN:VCARD\nEND:VCARD" }, + } as proto.IMessage), + ).toBe(true); + }); + + it("returns true for contactsArrayMessage via contact placeholder extraction", () => { + expect( + hasInboundUserContent({ + contactsArrayMessage: { + contacts: [{ displayName: "Alice", vcard: "BEGIN:VCARD\nEND:VCARD" }], + }, + } as proto.IMessage), + ).toBe(true); + }); + + it("returns true for buttons response (user button click)", () => { + expect( + hasInboundUserContent({ + buttonsResponseMessage: { + selectedButtonId: "yes", + selectedDisplayText: "Yes", + }, + } as proto.IMessage), + ).toBe(true); + }); + + it("returns true for list response (user list selection)", () => { + expect( + hasInboundUserContent({ + listResponseMessage: { + title: "Option A", + singleSelectReply: { selectedRowId: "a" }, + } as unknown as proto.Message.IListResponseMessage, + } as proto.IMessage), + ).toBe(true); + }); + + it("returns true for template button reply", () => { + expect( + hasInboundUserContent({ + templateButtonReplyMessage: { + selectedId: "btn-1", + selectedDisplayText: "Click", + } as unknown as proto.Message.ITemplateButtonReplyMessage, + } as proto.IMessage), + ).toBe(true); + }); + + it("returns true for interactive response", () => { + expect( + hasInboundUserContent({ + interactiveResponseMessage: { + body: { text: "x" }, + nativeFlowResponseMessage: { name: "n", paramsJson: "{}" }, + } as unknown as proto.Message.IInteractiveResponseMessage, + } as proto.IMessage), + ).toBe(true); + }); + + it("returns true for buttons response wrapped in ephemeralMessage (regression for #73797 + greptile review)", () => { + expect( + hasInboundUserContent({ + ephemeralMessage: { + message: { + buttonsResponseMessage: { + selectedButtonId: "ok", + selectedDisplayText: "OK", + }, + }, + }, + } as proto.IMessage), + ).toBe(true); + }); + + it("returns false for undefined message (regression for #73797)", () => { + expect(hasInboundUserContent(undefined)).toBe(false); + }); + + it("returns false for empty message object (no content keys)", () => { + expect(hasInboundUserContent({} as proto.IMessage)).toBe(false); + }); + + it("returns false for protocol message envelope without inner content (regression for #73797)", () => { + expect( + hasInboundUserContent({ + protocolMessage: { + type: 0, + } as unknown as proto.Message.IProtocolMessage, + } as proto.IMessage), + ).toBe(false); + }); + + it("returns false for receipt-style senderKeyDistribution-only payload (regression for #73797)", () => { + expect( + hasInboundUserContent({ + senderKeyDistributionMessage: { + groupId: "g@example", + } as unknown as proto.Message.ISenderKeyDistributionMessage, + } as proto.IMessage), + ).toBe(false); + }); + + it("returns false when location coords are missing (incomplete event, regression for #73797)", () => { + expect( + hasInboundUserContent({ + locationMessage: { name: "no coords" }, + } as proto.IMessage), + ).toBe(false); + }); + + it("returns false when extendedTextMessage has only empty text", () => { + expect(hasInboundUserContent({ extendedTextMessage: { text: " " } } as proto.IMessage)).toBe( + false, + ); + }); +}); diff --git a/extensions/whatsapp/src/inbound/extract.ts b/extensions/whatsapp/src/inbound/extract.ts index 84145ab0dbc..043fdab81ff 100644 --- a/extensions/whatsapp/src/inbound/extract.ts +++ b/extensions/whatsapp/src/inbound/extract.ts @@ -438,3 +438,49 @@ export function describeReplyContext( sender, }; } + +function hasInteractiveResponseContent(message: proto.IMessage | undefined): boolean { + if (!message) { + return false; + } + // Button/list/template/interactive selections that the existing four + // extractors do not cover. Treat any presence of these keys as user + // content — Baileys never delivers these as receipts or protocol + // envelopes, only as explicit user choices. + return Boolean( + message.buttonsResponseMessage || + message.listResponseMessage || + message.templateButtonReplyMessage || + message.interactiveResponseMessage, + ); +} + +/** + * Fast check that a Baileys message carries user-visible inbound content + * (text, media, contact, location, button/list selection). Returns false for + * protocol/receipt/typing notifications that arrive on the same + * `messages.upsert` stream as real messages but should not trigger pairing + * access-control side effects. + */ +export function hasInboundUserContent(rawMessage: proto.IMessage | undefined): boolean { + if (!rawMessage) { + return false; + } + if (extractText(rawMessage)) { + return true; + } + if (extractMediaPlaceholder(rawMessage)) { + return true; + } + if (extractLocationData(rawMessage)) { + return true; + } + // Walk wrappers (ephemeral, viewOnce, etc.) — interactive responses + // can arrive nested. + for (const candidate of buildMessageChain(rawMessage)) { + if (hasInteractiveResponseContent(candidate)) { + return true; + } + } + return false; +} diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index b55e689cd02..a2107afd0f2 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -35,6 +35,7 @@ import { extractMediaPlaceholder, extractMentionedJids, extractText, + hasInboundUserContent, } from "./extract.js"; import { attachEmitterListener, closeInboundMonitorSocket } from "./lifecycle.js"; import { downloadInboundMedia } from "./media.js"; @@ -381,6 +382,18 @@ export async function attachWebInboxToSocket( ); return null; } + // Gate pairing access-control on extractable inbound user content. Baileys + // delivers receipts, typing indicators, presence updates, and protocol + // messages on the same `messages.upsert` stream as real messages; without + // this gate, `checkInboundAccessControl` can send an unsolicited pairing + // verification reply to a `dmPolicy: pairing` peer who never typed + // anything (e.g. when Master sends an outbound message to a new JID and + // the receipt round-trip arrives before the recipient ever replies). + // Echoes of our own outbound messages are already handled above. + if (!hasInboundUserContent(msg.message ?? undefined)) { + return null; + } + const participantJid = msg.key?.participant ?? undefined; const from = group ? remoteJid : await resolveInboundJid(remoteJid); if (!from) {