From 5a1ab44baa6d68038ea452586197e5a9354d180e Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:44:23 +0800 Subject: [PATCH] fix(whatsapp): detect outbound media content before dispatch (#5203) --- nanobot/channels/whatsapp/runtime.py | 101 +++++++- .../whatsapp/tests/test_whatsapp_channel.py | 225 ++++++++++++++++-- 2 files changed, 300 insertions(+), 26 deletions(-) diff --git a/nanobot/channels/whatsapp/runtime.py b/nanobot/channels/whatsapp/runtime.py index e576bfe99..7a24e920c 100644 --- a/nanobot/channels/whatsapp/runtime.py +++ b/nanobot/channels/whatsapp/runtime.py @@ -12,7 +12,9 @@ from collections import OrderedDict from contextlib import suppress from pathlib import Path from typing import Any, Literal, NamedTuple, cast +from urllib.parse import urlparse +import httpx from pydantic import Field from nanobot.bus.events import OutboundMessage @@ -20,6 +22,7 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir, get_runtime_subdir from nanobot.config.schema import Base +from nanobot.security.network import PinnedDNSAsyncTransport class WhatsAppConfig(Base): @@ -39,6 +42,8 @@ class _NeonizeAPI(NamedTuple): MessageEv: Any PairStatusEv: Any build_jid: Any + detect_mime: Any + detect_buffer: Any class _MediaInfo(NamedTuple): @@ -52,6 +57,15 @@ class _MediaInfo(NamedTuple): _NEONIZE_API: _NeonizeAPI | None = None _JID_RE = re.compile(r"^(?P[^@]+)@(?P[^@]+)$") _LEGACY_BRIDGE_CONFIG_FIELDS = ("bridgeUrl", "bridgeToken", "bridge_url", "bridge_token") +_REMOTE_MEDIA_MAX_BYTES = 32 * 1024 * 1024 +_REMOTE_MEDIA_MAX_REDIRECTS = 5 +_REMOTE_MEDIA_TIMEOUT_SECONDS = 120.0 +# OGG is intentionally excluded: WhatsApp accepts only mono Opus, which MIME sniffing cannot prove. +_DIRECT_AUDIO_MIMETYPES = {"audio/aac", "audio/amr", "audio/mp4", "audio/mpeg"} +_MIMETYPE_ALIASES = { + "audio/x-hx-aac-adts": "audio/aac", + "audio/x-m4a": "audio/mp4", +} def _default_database_path() -> Path: @@ -68,9 +82,15 @@ def _load_neonize() -> _NeonizeAPI: return _NEONIZE_API try: + import magic from neonize.aioze.client import NewAClient from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv from neonize.utils.jid import build_jid + + detect_mime = getattr(magic, "from_file", None) + detect_buffer = getattr(magic, "from_buffer", None) + if not callable(detect_mime) or not callable(detect_buffer): + raise ImportError("python-magic does not expose from_file/from_buffer") except ImportError as exc: raise RuntimeError( "WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp" @@ -83,6 +103,8 @@ def _load_neonize() -> _NeonizeAPI: MessageEv=MessageEv, PairStatusEv=PairStatusEv, build_jid=build_jid, + detect_mime=detect_mime, + detect_buffer=detect_buffer, ) return _NEONIZE_API @@ -417,23 +439,84 @@ class WhatsAppChannel(BaseChannel): return api.build_jid(user, server) async def _send_media(self, client: Any, to: Any, media_path: str) -> None: - path = str(Path(media_path).expanduser()) - mime, _ = mimetypes.guess_type(path) - mimetype = mime or "application/octet-stream" + source: str | bytes + if media_path.startswith(("http://", "https://")): + source = await self._fetch_remote_media(media_path) + filename = Path(urlparse(media_path).path).name or "attachment" + else: + source = str(Path(media_path).expanduser()) + filename = Path(source).name + + mimetype = self._detect_mimetype(source) if mimetype.startswith("image/"): - await client.send_image(to, path) + await client.send_image(to, source) elif mimetype.startswith("video/"): - await client.send_video(to, path) - elif mimetype.startswith("audio/"): - await client.send_audio(to, path) + await client.send_video(to, source) + elif mimetype in _DIRECT_AUDIO_MIMETYPES: + await client.send_audio(to, source) else: await client.send_document( to, - path, - filename=Path(path).name, + source, + filename=filename, mimetype=mimetype, ) + async def _fetch_remote_media(self, url: str) -> bytes: + timeout = httpx.Timeout(_REMOTE_MEDIA_TIMEOUT_SECONDS, connect=10.0) + async with httpx.AsyncClient( + transport=PinnedDNSAsyncTransport(), + follow_redirects=True, + max_redirects=_REMOTE_MEDIA_MAX_REDIRECTS, + timeout=timeout, + trust_env=False, + ) as http: + async with http.stream("GET", url) as response: + response.raise_for_status() + declared_size = response.headers.get("content-length") + if ( + declared_size + and declared_size.isdigit() + and int(declared_size) > _REMOTE_MEDIA_MAX_BYTES + ): + raise ValueError( + f"Remote WhatsApp media exceeds the {_REMOTE_MEDIA_MAX_BYTES}-byte limit" + ) + + chunks: list[bytes] = [] + total = 0 + async for chunk in response.aiter_bytes(): + total += len(chunk) + if total > _REMOTE_MEDIA_MAX_BYTES: + raise ValueError( + f"Remote WhatsApp media exceeds the {_REMOTE_MEDIA_MAX_BYTES}-byte limit" + ) + chunks.append(chunk) + return b"".join(chunks) + + def _detect_mimetype(self, source: str | bytes) -> str: + try: + api = _load_neonize() + detected = ( + api.detect_buffer(source, mime=True) + if isinstance(source, bytes) + else api.detect_mime(source, mime=True) + ) + except Exception as exc: + label = f"{len(source)} downloaded bytes" if isinstance(source, bytes) else source + self.logger.debug("Failed to inspect WhatsApp media {}: {}", label, exc) + detected = None + + if isinstance(detected, str) and "/" in detected: + mimetype = detected.partition(";")[0].strip().lower() + return _MIMETYPE_ALIASES.get(mimetype, mimetype) + + if isinstance(source, bytes): + return "application/octet-stream" + + guessed, _ = mimetypes.guess_type(source) + return guessed or "application/octet-stream" + def _register_handlers( self, client: Any, diff --git a/nanobot/channels/whatsapp/tests/test_whatsapp_channel.py b/nanobot/channels/whatsapp/tests/test_whatsapp_channel.py index 86dd0668c..ce489e980 100644 --- a/nanobot/channels/whatsapp/tests/test_whatsapp_channel.py +++ b/nanobot/channels/whatsapp/tests/test_whatsapp_channel.py @@ -1,11 +1,13 @@ from __future__ import annotations import asyncio +import mimetypes import sys import types from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock +import httpx import pytest import nanobot.channels.whatsapp.runtime as whatsapp_module @@ -78,7 +80,21 @@ def _make_channel(config: dict | None = None) -> WhatsAppChannel: return ch -def _patch_neonize_api(monkeypatch) -> None: +def _make_send_client() -> SimpleNamespace: + return SimpleNamespace( + send_message=AsyncMock(), + send_image=AsyncMock(), + send_video=AsyncMock(), + send_audio=AsyncMock(), + send_document=AsyncMock(), + ) + + +def _patch_neonize_api(monkeypatch, detect_mime=None, detect_buffer=None) -> None: + detect_mime = detect_mime or ( + lambda path, *, mime: mimetypes.guess_type(path)[0] or "application/octet-stream" + ) + detect_buffer = detect_buffer or (lambda data, *, mime: "application/octet-stream") monkeypatch.setattr( whatsapp_module, "_NEONIZE_API", @@ -89,6 +105,8 @@ def _patch_neonize_api(monkeypatch) -> None: MessageEv=object(), PairStatusEv=object(), build_jid=lambda user, server="s.whatsapp.net": (user, server), + detect_mime=detect_mime, + detect_buffer=detect_buffer, ), ) @@ -178,13 +196,7 @@ async def test_login_fails_when_connect_task_fails(monkeypatch) -> None: @pytest.mark.asyncio async def test_send_text_uses_neonize_send_message(monkeypatch) -> None: _patch_neonize_api(monkeypatch) - client = SimpleNamespace( - send_message=AsyncMock(), - send_image=AsyncMock(), - send_video=AsyncMock(), - send_audio=AsyncMock(), - send_document=AsyncMock(), - ) + client = _make_send_client() ch = _make_channel() ch._client = client ch._connected = True @@ -197,13 +209,7 @@ async def test_send_text_uses_neonize_send_message(monkeypatch) -> None: @pytest.mark.asyncio async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None: _patch_neonize_api(monkeypatch) - client = SimpleNamespace( - send_message=AsyncMock(), - send_image=AsyncMock(), - send_video=AsyncMock(), - send_audio=AsyncMock(), - send_document=AsyncMock(), - ) + client = _make_send_client() ch = _make_channel() ch._client = client ch._connected = True @@ -213,14 +219,14 @@ async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None: channel="whatsapp", chat_id="12345@s.whatsapp.net", content="", - media=["photo.jpg", "clip.mp4", "voice.ogg", "report.pdf"], + media=["photo.jpg", "clip.mp4", "voice.mp3", "report.pdf"], ) ) jid = ("12345", "s.whatsapp.net") client.send_image.assert_awaited_once_with(jid, "photo.jpg") client.send_video.assert_awaited_once_with(jid, "clip.mp4") - client.send_audio.assert_awaited_once_with(jid, "voice.ogg") + client.send_audio.assert_awaited_once_with(jid, "voice.mp3") client.send_document.assert_awaited_once_with( jid, "report.pdf", @@ -229,6 +235,191 @@ async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None: ) +@pytest.mark.asyncio +async def test_send_mislabeled_audio_as_document(monkeypatch) -> None: + _patch_neonize_api(monkeypatch, detect_mime=lambda path, *, mime: "audio/x-wav") + client = _make_send_client() + ch = _make_channel() + ch._client = client + ch._connected = True + + await ch.send( + OutboundMessage( + channel="whatsapp", + chat_id="12345@s.whatsapp.net", + content="", + media=["recording.mpeg"], + ) + ) + + jid = ("12345", "s.whatsapp.net") + client.send_document.assert_awaited_once_with( + jid, + "recording.mpeg", + filename="recording.mpeg", + mimetype="audio/x-wav", + ) + client.send_video.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_send_remote_mislabeled_audio_as_document(monkeypatch) -> None: + payload = b"remote wav payload" + media_url = "https://cdn.example/recording.mpeg?token=secret" + + def handle_request(request: httpx.Request) -> httpx.Response: + assert str(request.url) == media_url + return httpx.Response(200, content=payload) + + monkeypatch.setattr( + whatsapp_module, + "PinnedDNSAsyncTransport", + lambda: httpx.MockTransport(handle_request), + ) + + def detect_buffer(data: bytes, *, mime: bool) -> str: + assert data == payload + assert mime is True + return "audio/x-wav" + + _patch_neonize_api( + monkeypatch, + detect_buffer=detect_buffer, + ) + client = _make_send_client() + ch = _make_channel() + ch._client = client + ch._connected = True + + await ch.send( + OutboundMessage( + channel="whatsapp", + chat_id="12345@s.whatsapp.net", + content="", + media=[media_url], + ) + ) + + jid = ("12345", "s.whatsapp.net") + client.send_document.assert_awaited_once_with( + jid, + payload, + filename="recording.mpeg", + mimetype="audio/x-wav", + ) + client.send_video.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_send_remote_media_blocks_private_url(monkeypatch) -> None: + _patch_neonize_api(monkeypatch) + client = _make_send_client() + ch = _make_channel() + ch._client = client + ch._connected = True + + with pytest.raises(httpx.RequestError, match="private/internal"): + await ch.send( + OutboundMessage( + channel="whatsapp", + chat_id="12345@s.whatsapp.net", + content="", + media=["http://127.0.0.1/recording.mpeg"], + ) + ) + + client.send_video.assert_not_awaited() + client.send_document.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_send_remote_media_enforces_download_limit(monkeypatch) -> None: + monkeypatch.setattr(whatsapp_module, "_REMOTE_MEDIA_MAX_BYTES", 3) + monkeypatch.setattr( + whatsapp_module, + "PinnedDNSAsyncTransport", + lambda: httpx.MockTransport(lambda request: httpx.Response(200, content=b"1234")), + ) + _patch_neonize_api(monkeypatch) + client = _make_send_client() + ch = _make_channel() + ch._client = client + ch._connected = True + + with pytest.raises(ValueError, match="exceeds the 3-byte limit"): + await ch.send( + OutboundMessage( + channel="whatsapp", + chat_id="12345@s.whatsapp.net", + content="", + media=["https://cdn.example/recording.mpeg"], + ) + ) + + client.send_video.assert_not_awaited() + client.send_document.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_send_unsupported_ogg_audio_as_document(monkeypatch) -> None: + _patch_neonize_api(monkeypatch, detect_mime=lambda path, *, mime: "audio/ogg") + client = _make_send_client() + ch = _make_channel() + ch._client = client + ch._connected = True + + await ch.send( + OutboundMessage( + channel="whatsapp", + chat_id="12345@s.whatsapp.net", + content="", + media=["voice.ogg"], + ) + ) + + jid = ("12345", "s.whatsapp.net") + client.send_document.assert_awaited_once_with( + jid, + "voice.ogg", + filename="voice.ogg", + mimetype="audio/ogg", + ) + client.send_audio.assert_not_awaited() + + +@pytest.mark.parametrize( + ("detected_mimetype", "filename"), + [ + ("audio/x-m4a", "recording.m4a"), + ("audio/x-hx-aac-adts", "recording.aac"), + ], +) +@pytest.mark.asyncio +async def test_send_supported_audio_magic_aliases_inline( + monkeypatch, detected_mimetype: str, filename: str +) -> None: + _patch_neonize_api( + monkeypatch, + detect_mime=lambda path, *, mime: detected_mimetype, + ) + client = _make_send_client() + ch = _make_channel() + ch._client = client + ch._connected = True + + await ch.send( + OutboundMessage( + channel="whatsapp", + chat_id="12345@s.whatsapp.net", + content="", + media=[filename], + ) + ) + + client.send_audio.assert_awaited_once_with(("12345", "s.whatsapp.net"), filename) + client.send_document.assert_not_awaited() + + @pytest.mark.asyncio async def test_send_when_disconnected_raises() -> None: ch = _make_channel()