mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 09:28:34 +00:00
fix(whatsapp): inspect remote media before dispatch
This commit is contained in:
parent
47029757e5
commit
42ebe3a6b0
@ -12,7 +12,9 @@ from collections import OrderedDict
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, NamedTuple, cast
|
from typing import Any, Literal, NamedTuple, cast
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
@ -20,6 +22,7 @@ from nanobot.bus.queue import MessageBus
|
|||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_media_dir, get_runtime_subdir
|
from nanobot.config.paths import get_media_dir, get_runtime_subdir
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
|
from nanobot.security.network import PinnedDNSAsyncTransport
|
||||||
|
|
||||||
|
|
||||||
class WhatsAppConfig(Base):
|
class WhatsAppConfig(Base):
|
||||||
@ -40,6 +43,7 @@ class _NeonizeAPI(NamedTuple):
|
|||||||
PairStatusEv: Any
|
PairStatusEv: Any
|
||||||
build_jid: Any
|
build_jid: Any
|
||||||
detect_mime: Any
|
detect_mime: Any
|
||||||
|
detect_buffer: Any
|
||||||
|
|
||||||
|
|
||||||
class _MediaInfo(NamedTuple):
|
class _MediaInfo(NamedTuple):
|
||||||
@ -53,6 +57,9 @@ class _MediaInfo(NamedTuple):
|
|||||||
_NEONIZE_API: _NeonizeAPI | None = None
|
_NEONIZE_API: _NeonizeAPI | None = None
|
||||||
_JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$")
|
_JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$")
|
||||||
_LEGACY_BRIDGE_CONFIG_FIELDS = ("bridgeUrl", "bridgeToken", "bridge_url", "bridge_token")
|
_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.
|
# 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"}
|
_DIRECT_AUDIO_MIMETYPES = {"audio/aac", "audio/amr", "audio/mp4", "audio/mpeg"}
|
||||||
_MIMETYPE_ALIASES = {
|
_MIMETYPE_ALIASES = {
|
||||||
@ -81,8 +88,9 @@ def _load_neonize() -> _NeonizeAPI:
|
|||||||
from neonize.utils.jid import build_jid
|
from neonize.utils.jid import build_jid
|
||||||
|
|
||||||
detect_mime = getattr(magic, "from_file", None)
|
detect_mime = getattr(magic, "from_file", None)
|
||||||
if not callable(detect_mime):
|
detect_buffer = getattr(magic, "from_buffer", None)
|
||||||
raise ImportError("python-magic does not expose from_file")
|
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:
|
except ImportError as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp"
|
"WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp"
|
||||||
@ -96,6 +104,7 @@ def _load_neonize() -> _NeonizeAPI:
|
|||||||
PairStatusEv=PairStatusEv,
|
PairStatusEv=PairStatusEv,
|
||||||
build_jid=build_jid,
|
build_jid=build_jid,
|
||||||
detect_mime=detect_mime,
|
detect_mime=detect_mime,
|
||||||
|
detect_buffer=detect_buffer,
|
||||||
)
|
)
|
||||||
return _NEONIZE_API
|
return _NEONIZE_API
|
||||||
|
|
||||||
@ -430,34 +439,82 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
return api.build_jid(user, server)
|
return api.build_jid(user, server)
|
||||||
|
|
||||||
async def _send_media(self, client: Any, to: Any, media_path: str) -> None:
|
async def _send_media(self, client: Any, to: Any, media_path: str) -> None:
|
||||||
path = str(Path(media_path).expanduser())
|
source: str | bytes
|
||||||
mimetype = self._detect_mimetype(path)
|
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/"):
|
if mimetype.startswith("image/"):
|
||||||
await client.send_image(to, path)
|
await client.send_image(to, source)
|
||||||
elif mimetype.startswith("video/"):
|
elif mimetype.startswith("video/"):
|
||||||
await client.send_video(to, path)
|
await client.send_video(to, source)
|
||||||
elif mimetype in _DIRECT_AUDIO_MIMETYPES:
|
elif mimetype in _DIRECT_AUDIO_MIMETYPES:
|
||||||
await client.send_audio(to, path)
|
await client.send_audio(to, source)
|
||||||
else:
|
else:
|
||||||
await client.send_document(
|
await client.send_document(
|
||||||
to,
|
to,
|
||||||
path,
|
source,
|
||||||
filename=Path(path).name,
|
filename=filename,
|
||||||
mimetype=mimetype,
|
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:
|
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:
|
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
|
detected = None
|
||||||
|
|
||||||
if isinstance(detected, str) and "/" in detected:
|
if isinstance(detected, str) and "/" in detected:
|
||||||
mimetype = detected.partition(";")[0].strip().lower()
|
mimetype = detected.partition(";")[0].strip().lower()
|
||||||
return _MIMETYPE_ALIASES.get(mimetype, mimetype)
|
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"
|
return guessed or "application/octet-stream"
|
||||||
|
|
||||||
def _register_handlers(
|
def _register_handlers(
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import types
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import nanobot.channels.whatsapp.runtime as whatsapp_module
|
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 (
|
detect_mime = detect_mime or (
|
||||||
lambda path, *, mime: mimetypes.guess_type(path)[0] or "application/octet-stream"
|
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(
|
monkeypatch.setattr(
|
||||||
whatsapp_module,
|
whatsapp_module,
|
||||||
"_NEONIZE_API",
|
"_NEONIZE_API",
|
||||||
@ -104,6 +106,7 @@ def _patch_neonize_api(monkeypatch, detect_mime=None) -> None:
|
|||||||
PairStatusEv=object(),
|
PairStatusEv=object(),
|
||||||
build_jid=lambda user, server="s.whatsapp.net": (user, server),
|
build_jid=lambda user, server="s.whatsapp.net": (user, server),
|
||||||
detect_mime=detect_mime,
|
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()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_unsupported_ogg_audio_as_document(monkeypatch) -> None:
|
async def test_send_unsupported_ogg_audio_as_document(monkeypatch) -> None:
|
||||||
_patch_neonize_api(monkeypatch, detect_mime=lambda path, *, mime: "audio/ogg")
|
_patch_neonize_api(monkeypatch, detect_mime=lambda path, *, mime: "audio/ogg")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user