fix(matrix): gate require_mention / allowed rooms before downloading inbound media

`_handle_media_message` fetched the mxc:// payload (`_download_and_cache_media`)
before `_build_inbound_event` ran `_resolve_message_context`, so an unmentioned
or non-allowlisted room's m.image/m.file was pulled onto the host and then
dropped. Resolve the context first and hand it to `_build_inbound_event` via a
new `ctx` kwarg so the read receipt / thread mark are still applied once.

Same class as the Teams fix in this PR (review follow-up). One invariant test:
unmentioned group media → 0 downloads, mentioned → 1.
This commit is contained in:
teknium1
2026-09-18 03:03:41 -07:00
committed by Teknium
parent c619f58ad9
commit 7f13d3f43d
2 changed files with 63 additions and 4 deletions

View File

@@ -2087,10 +2087,13 @@ class MatrixAdapter(BasePlatformAdapter):
async def _build_inbound_event(
self, room_id: str, sender: str, event_id: str, body: str, source_content: dict, relates_to: dict,
**extra) -> Optional[MessageEvent]:
ctx: Optional[tuple] = None, **extra) -> Optional[MessageEvent]:
"""Gate + normalise an inbound event into a MessageEvent (None => drop). Text body may
still change (reply-fallback strip); ``extra`` carries media fields / message_type."""
ctx = await self._resolve_message_context(room_id, sender, event_id, body, source_content, relates_to)
still change (reply-fallback strip); ``extra`` carries media fields / message_type.
``ctx`` is a pre-resolved ``_resolve_message_context`` result (media path gates before
downloading); resolving it twice would double the read receipt / thread mark."""
if ctx is None:
ctx = await self._resolve_message_context(room_id, sender, event_id, body, source_content, relates_to)
if ctx is None:
return None
body, _is_dm, _chat_type, _thread_id, display_name, source = ctx
@@ -2154,6 +2157,11 @@ class MatrixAdapter(BasePlatformAdapter):
return
is_encrypted_media = bool(file_content and isinstance(file_content, dict) and file_content.get("url"))
msg_type, media_type, is_voice_message = self._classify_inbound_media(msgtype, event_mimetype, source_content)
# Gate (require_mention / allowed rooms) BEFORE the download: an unmentioned or
# non-allowlisted room must not pull media onto the host only to drop it.
ctx = await self._resolve_message_context(room_id, sender, event_id, body, source_content, relates_to)
if ctx is None:
return
# Cache locally so downstream tools get a real file path.
cached_path = None
if url:
@@ -2167,7 +2175,7 @@ class MatrixAdapter(BasePlatformAdapter):
http_url = self._mxc_to_http(url) if url and not is_encrypted_media else ""
media_urls = [cached_path] if cached_path else ([http_url] if http_url else None)
msg_event = await self._build_inbound_event(
room_id, sender, event_id, body, source_content, relates_to, message_type=msg_type,
room_id, sender, event_id, body, source_content, relates_to, ctx=ctx, message_type=msg_type,
media_urls=media_urls, media_types=[media_type] if media_urls else None, media_msgtype=msgtype)
if msg_event is not None:
await self.handle_message(msg_event)

View File

@@ -0,0 +1,51 @@
"""Matrix must gate (require_mention / allowed rooms) BEFORE downloading inbound media.
An unmentioned ``m.image`` in a gated group room used to be fetched from the homeserver
(``_download_and_cache_media``) and only then dropped by ``_resolve_message_context``.
The invariant: a dropped media event performs zero downloads; a mentioned one still does.
"""
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
def _make_adapter(monkeypatch):
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true")
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
from gateway.config import PlatformConfig
from plugins.platforms.matrix.adapter import MatrixAdapter
adapter = MatrixAdapter(PlatformConfig(
enabled=True, token="syt_test_token",
extra={"homeserver": "https://matrix.example.org", "user_id": "@hermes:example.org"}))
adapter._startup_ts = time.time() - 10
adapter.handle_message = AsyncMock()
adapter._client = None
adapter._resolve_room_identity = AsyncMock(return_value=SimpleNamespace(
display_name="Group Room", room_topic=None, server_name="example.org", chat_type="group"))
adapter._is_dm_room = AsyncMock(return_value=False)
adapter._download_and_cache_media = AsyncMock(return_value="/tmp/cached.png")
return adapter
def _image_event(body):
return SimpleNamespace(
sender="@alice:example.org", event_id="$img1", room_id="!group:example.org",
timestamp=int(time.time() * 1000),
content={"body": body, "msgtype": "m.image", "url": "mxc://example.org/abc",
"info": {"mimetype": "image/png", "size": 1024}})
@pytest.mark.asyncio
@pytest.mark.parametrize("body, downloads, dispatched", [
("photo.png", 0, 0), # unmentioned group media: never fetched
("@hermes:example.org look", 1, 1), # mentioned: fetched and dispatched
])
async def test_unmentioned_group_media_is_not_downloaded(monkeypatch, body, downloads, dispatched):
adapter = _make_adapter(monkeypatch)
await adapter._on_room_message(_image_event(body))
assert adapter._download_and_cache_media.await_count == downloads
assert adapter.handle_message.await_count == dispatched