diff --git a/nanobot/channels/whatsapp/runtime.py b/nanobot/channels/whatsapp/runtime.py index dcd9a5740..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): @@ -40,6 +43,7 @@ class _NeonizeAPI(NamedTuple): PairStatusEv: Any build_jid: Any detect_mime: Any + detect_buffer: Any class _MediaInfo(NamedTuple): @@ -53,6 +57,9 @@ 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 = { @@ -81,8 +88,9 @@ def _load_neonize() -> _NeonizeAPI: from neonize.utils.jid import build_jid detect_mime = getattr(magic, "from_file", None) - if not callable(detect_mime): - raise ImportError("python-magic does not expose from_file") + 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" @@ -96,6 +104,7 @@ def _load_neonize() -> _NeonizeAPI: PairStatusEv=PairStatusEv, build_jid=build_jid, detect_mime=detect_mime, + detect_buffer=detect_buffer, ) return _NEONIZE_API @@ -430,34 +439,82 @@ 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()) - mimetype = self._detect_mimetype(path) + 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) + await client.send_video(to, source) elif mimetype in _DIRECT_AUDIO_MIMETYPES: - await client.send_audio(to, path) + await client.send_audio(to, source) else: await client.send_document( to, - path, - filename=Path(path).name, + source, + filename=filename, mimetype=mimetype, ) - def _detect_mimetype(self, path: str) -> str: + 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: - detected = _load_neonize().detect_mime(path, mime=True) + 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: - self.logger.debug("Failed to inspect WhatsApp media {}: {}", path, 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) - guessed, _ = mimetypes.guess_type(path) + if isinstance(source, bytes): + return "application/octet-stream" + + guessed, _ = mimetypes.guess_type(source) return guessed or "application/octet-stream" def _register_handlers( diff --git a/nanobot/channels/whatsapp/tests/test_whatsapp_channel.py b/nanobot/channels/whatsapp/tests/test_whatsapp_channel.py index 2da369802..ce489e980 100644 --- a/nanobot/channels/whatsapp/tests/test_whatsapp_channel.py +++ b/nanobot/channels/whatsapp/tests/test_whatsapp_channel.py @@ -7,6 +7,7 @@ import types from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock +import httpx import pytest import nanobot.channels.whatsapp.runtime as whatsapp_module @@ -89,10 +90,11 @@ def _make_send_client() -> SimpleNamespace: ) -def _patch_neonize_api(monkeypatch, detect_mime=None) -> None: +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", @@ -104,6 +106,7 @@ def _patch_neonize_api(monkeypatch, detect_mime=None) -> None: PairStatusEv=object(), build_jid=lambda user, server="s.whatsapp.net": (user, server), detect_mime=detect_mime, + detect_buffer=detect_buffer, ), ) @@ -259,6 +262,104 @@ async def test_send_mislabeled_audio_as_document(monkeypatch) -> None: 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")