diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d1b248b..7615a8f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -142,6 +142,7 @@ Docs: https://docs.openclaw.ai - Security/Audit: make `gateway.real_ip_fallback_enabled` severity conditional for loopback trusted-proxy setups (warn for loopback-only `trustedProxies`, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3. - Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec env: block `SHELLOPTS`/`PS4` in host exec env sanitizers and restrict shell-wrapper (`bash|sh|zsh ... -c/-lc`) request env overrides to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`) on both node host and macOS companion paths, preventing xtrace prompt command-substitution allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- WhatsApp/Security: enforce `allowFrom` for direct-message outbound targets in all send modes (including `mode: "explicit"`), preventing sends to non-allowlisted numbers. (#20108) Thanks @zahlmann. - Security/Exec approvals: fail closed on shell line continuations (`\\\n`/`\\\r\n`) and treat shell-wrapper execution as approval-required in allowlist mode, preventing `$\\` newline command-substitution bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Gateway: emit a startup security warning when insecure/dangerous config flags are enabled (including `gateway.controlUi.dangerouslyDisableDeviceAuth=true`) and point operators to `openclaw security audit`. - Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. This ships in the next npm release. Thanks @aether-ai-agent for reporting. diff --git a/src/whatsapp/resolve-outbound-target.test.ts b/src/whatsapp/resolve-outbound-target.test.ts index 18fe322bc..b97f5646c 100644 --- a/src/whatsapp/resolve-outbound-target.test.ts +++ b/src/whatsapp/resolve-outbound-target.test.ts @@ -208,8 +208,8 @@ describe("resolveWhatsAppOutboundTarget", () => { }); }); - describe("other modes (allow all valid targets)", () => { - it("allows message in null mode", () => { + describe("explicit/custom modes", () => { + it("allows message in null mode when allowList is not set", () => { vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce("+11234567890"); vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); @@ -223,7 +223,7 @@ describe("resolveWhatsAppOutboundTarget", () => { ); }); - it("allows message in undefined mode", () => { + it("allows message in undefined mode when allowList is not set", () => { vi.mocked(normalize.normalizeWhatsAppTarget).mockReturnValueOnce("+11234567890"); vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); @@ -237,16 +237,29 @@ describe("resolveWhatsAppOutboundTarget", () => { ); }); - it("allows message in custom mode string", () => { + it("enforces allowList in custom mode string", () => { vi.mocked(normalize.normalizeWhatsAppTarget) .mockReturnValueOnce("+19876543210") // for allowFrom[0] (happens first!) .mockReturnValueOnce("+11234567890"); // for 'to' param (happens second) vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); + expectResolutionError({ + to: "+11234567890", + allowFrom: ["+19876543210"], + mode: "broadcast", + }); + }); + + it("allows message in custom mode string when target is in allowList", () => { + vi.mocked(normalize.normalizeWhatsAppTarget) + .mockReturnValueOnce("+11234567890") // for allowFrom[0] + .mockReturnValueOnce("+11234567890"); // for 'to' param + vi.mocked(normalize.isWhatsAppGroupJid).mockReturnValueOnce(false); + expectResolutionOk( { to: "+11234567890", - allowFrom: ["+19876543210"], + allowFrom: ["+11234567890"], mode: "broadcast", }, "+11234567890", diff --git a/src/whatsapp/resolve-outbound-target.ts b/src/whatsapp/resolve-outbound-target.ts index 05e68e834..7ad7af1cd 100644 --- a/src/whatsapp/resolve-outbound-target.ts +++ b/src/whatsapp/resolve-outbound-target.ts @@ -31,19 +31,18 @@ export function resolveWhatsAppOutboundTarget(params: { if (isWhatsAppGroupJid(normalizedTo)) { return { ok: true, to: normalizedTo }; } - if (params.mode === "implicit" || params.mode === "heartbeat") { - if (hasWildcard || allowList.length === 0) { - return { ok: true, to: normalizedTo }; - } - if (allowList.includes(normalizedTo)) { - return { ok: true, to: normalizedTo }; - } - return { - ok: false, - error: missingTargetError("WhatsApp", ""), - }; + // Enforce allowFrom for all direct-message send modes (including explicit). + // Group destinations are handled by group policy and are allowed above. + if (hasWildcard || allowList.length === 0) { + return { ok: true, to: normalizedTo }; } - return { ok: true, to: normalizedTo }; + if (allowList.includes(normalizedTo)) { + return { ok: true, to: normalizedTo }; + } + return { + ok: false, + error: missingTargetError("WhatsApp", ""), + }; } return {