diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index df8e23f7f6..b3d7744e28 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -2291,6 +2291,25 @@ class MatrixAdapter(BasePlatformAdapter): # direct invite joined via reconciliation is never recorded in # m.direct and gets misclassified as a group. is_direct, inviter = self._extract_invite_dm_signal(invited_room) + # The inviter allowlist gate from _on_invite applies here too. + # Without it, an invite from an arbitrary federated user that + # arrives while the gateway is down would be auto-joined on + # restart, bypassing the gate. An inviter missing from the + # stripped invite state fails closed, like an empty sender in + # _on_invite. + if not self._is_authorized_user(inviter): + logger.warning( + "Matrix: rejecting invite to %s from unauthorized user %s", + room_id, + inviter, + ) + continue + if is_direct and not inviter: + logger.warning( + "Matrix: joining direct invite to %s without recording it " + "in m.direct because the invite state has no inviter", + room_id, + ) logger.info( "Matrix: reconciling pending invite for %s (is_direct=%s)", room_id, @@ -2309,6 +2328,9 @@ class MatrixAdapter(BasePlatformAdapter): ``is_direct`` flag from the original invite; its sender is the inviter. Returns ``(False, "")`` when the signal is absent. """ + if not self._user_id: + return False, "" + if not isinstance(invited_room, dict): return False, "" @@ -2325,7 +2347,7 @@ class MatrixAdapter(BasePlatformAdapter): continue if event.get("type") != "m.room.member": continue - if self._user_id and event.get("state_key") != self._user_id: + if event.get("state_key") != self._user_id: continue content = event.get("content", {}) diff --git a/tests/gateway/test_matrix_dm_invite_recording.py b/tests/gateway/test_matrix_dm_invite_recording.py index e73d439dbe..ed8c8eae01 100644 --- a/tests/gateway/test_matrix_dm_invite_recording.py +++ b/tests/gateway/test_matrix_dm_invite_recording.py @@ -183,34 +183,20 @@ class TestPendingInviteReconciliationRecordsDM: ) assert adapter._dm_rooms.get("!dm_room:example.org") is True - @pytest.mark.parametrize( - "invite_state", - [ - pytest.param(None, id="no-invite-state"), - pytest.param({"events": []}, id="empty-events"), - pytest.param( - {"events": [_member_invite_event(is_direct=False)]}, - id="not-direct", - ), - pytest.param( - {"events": [_member_invite_event(state_key="@other:example.org")]}, - id="member-event-for-other-user", - ), - pytest.param( - {"events": [_member_invite_event(sender="")]}, - id="missing-inviter", - ), - ], - ) @pytest.mark.asyncio - async def test_reconciled_invite_without_dm_signal_does_not_record( - self, invite_state - ): + async def test_reconciled_non_direct_invite_does_not_record(self): + """A pending invite without the is_direct flag joins (the inviter + is allow-listed) but records nothing in m.direct. Invites whose + inviter cannot be read from the stripped state at all are covered + by test_matrix_pending_invite_auth.py: they are rejected outright + by the inviter allowlist gate, so no join happens either.""" adapter = _make_adapter() adapter._join_room_by_id = AsyncMock(return_value=True) adapter._record_dm_room = AsyncMock() - sync_data = _invite_sync_data(invite_state=invite_state) + sync_data = _invite_sync_data( + invite_state={"events": [_member_invite_event(is_direct=False)]} + ) adapter._schedule_pending_invite_joins(sync_data) await self._drain_invite_tasks(adapter) diff --git a/tests/gateway/test_matrix_pending_invite_auth.py b/tests/gateway/test_matrix_pending_invite_auth.py new file mode 100644 index 0000000000..eb4a998b52 --- /dev/null +++ b/tests/gateway/test_matrix_pending_invite_auth.py @@ -0,0 +1,190 @@ +"""Tests for the inviter allowlist gate on pending-invite reconciliation. + +``_on_invite`` only auto-joins when the inviter is allow-listed, but a +pending invite reconciled from sync's ``rooms.invite`` after a gateway +restart never fires ``_on_invite``. ``_schedule_pending_invite_joins`` +must apply the same gate, reading the inviter from the stripped invite +state, or an invite from an arbitrary federated user that arrives while +the gateway is down gets auto-joined on restart. +""" + +import logging +import time +from unittest.mock import AsyncMock + +import pytest + +from gateway.config import PlatformConfig + + +def _make_adapter(): + """Create a MatrixAdapter with mocked config and a one-user allowlist.""" + from plugins.platforms.matrix.adapter import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test_token", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@hermes:example.org", + }, + ) + adapter = MatrixAdapter(config) + adapter._text_batch_delay_seconds = 0 + adapter.handle_message = AsyncMock() + adapter._startup_ts = time.time() - 10 + adapter._allowed_user_ids = {"@alice:example.org"} + adapter._join_room_by_id = AsyncMock(return_value=True) + adapter._record_dm_room = AsyncMock() + return adapter + + +def _member_invite_event( + state_key="@hermes:example.org", + sender="@alice:example.org", + is_direct=True, + membership="invite", +): + """Create a stripped m.room.member event as found in invite_state.""" + return { + "type": "m.room.member", + "state_key": state_key, + "sender": sender, + "content": {"membership": membership, "is_direct": is_direct}, + } + + +def _invite_sync_data(room_id="!pending_room:example.org", invite_state=None): + """Create a sync payload with one pending invite room.""" + room = {} if invite_state is None else {"invite_state": invite_state} + return {"rooms": {"invite": {room_id: room}}, "next_batch": "s1"} + + +async def _drain_invite_tasks(adapter): + """Await any tasks _schedule_invite_join spawned.""" + for task in list(adapter._invite_join_tasks.values()): + await task + + +class TestPendingInviteAuthorization: + """_schedule_pending_invite_joins applies _on_invite's inviter gate. + + Rejection mirrors _on_invite exactly: no join is scheduled (so no + entry lands in _invite_join_tasks), nothing is recorded in m.direct, + and the pending invite is otherwise left untouched. + """ + + @pytest.mark.asyncio + async def test_allowed_inviter_is_joined(self): + adapter = _make_adapter() + + sync_data = _invite_sync_data(invite_state={"events": [_member_invite_event()]}) + adapter._schedule_pending_invite_joins(sync_data) + await _drain_invite_tasks(adapter) + + adapter._join_room_by_id.assert_awaited_once_with("!pending_room:example.org") + adapter._record_dm_room.assert_awaited_once_with( + "!pending_room:example.org", "@alice:example.org" + ) + + @pytest.mark.asyncio + async def test_unknown_bot_user_id_fails_closed(self): + adapter = _make_adapter() + adapter._user_id = "" + + sync_data = _invite_sync_data(invite_state={"events": [_member_invite_event()]}) + adapter._schedule_pending_invite_joins(sync_data) + await _drain_invite_tasks(adapter) + + adapter._join_room_by_id.assert_not_awaited() + adapter._record_dm_room.assert_not_awaited() + assert adapter._invite_join_tasks == {} + + @pytest.mark.parametrize( + "invite_state", + [ + pytest.param( + {"events": [_member_invite_event(sender="@mallory:evil.example")]}, + id="non-allowed-inviter", + ), + pytest.param(None, id="no-invite-state"), + pytest.param({"events": []}, id="empty-events"), + pytest.param( + {"events": [_member_invite_event(state_key="@other:example.org")]}, + id="member-event-for-other-user", + ), + pytest.param( + {"events": [_member_invite_event(sender="")]}, + id="missing-inviter", + ), + ], + ) + @pytest.mark.asyncio + async def test_unauthorized_or_unknown_inviter_is_not_joined(self, invite_state): + """An inviter outside the allowlist, or one that cannot be read + from the stripped invite state at all, fails closed like + _on_invite: no join is scheduled and nothing is recorded.""" + adapter = _make_adapter() + + sync_data = _invite_sync_data(invite_state=invite_state) + adapter._schedule_pending_invite_joins(sync_data) + await _drain_invite_tasks(adapter) + + adapter._join_room_by_id.assert_not_awaited() + adapter._record_dm_room.assert_not_awaited() + assert adapter._invite_join_tasks == {} + + @pytest.mark.asyncio + async def test_empty_allowlist_fails_closed(self): + """With no allowlist configured, _on_invite rejects every invite; + reconciliation must do the same.""" + adapter = _make_adapter() + adapter._allowed_user_ids = set() + + sync_data = _invite_sync_data(invite_state={"events": [_member_invite_event()]}) + adapter._schedule_pending_invite_joins(sync_data) + await _drain_invite_tasks(adapter) + + adapter._join_room_by_id.assert_not_awaited() + adapter._record_dm_room.assert_not_awaited() + + @pytest.mark.asyncio + async def test_allow_all_env_bypasses_gate(self, monkeypatch): + """GATEWAY_ALLOW_ALL_USERS disables the gate, exactly as it does + for _on_invite, even when the inviter is unknown.""" + monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true") + adapter = _make_adapter() + adapter._allowed_user_ids = set() + + sync_data = _invite_sync_data(invite_state=None) + adapter._schedule_pending_invite_joins(sync_data) + await _drain_invite_tasks(adapter) + + adapter._join_room_by_id.assert_awaited_once_with("!pending_room:example.org") + adapter._record_dm_room.assert_not_awaited() + + @pytest.mark.asyncio + async def test_allow_all_logs_direct_invite_without_inviter( + self, monkeypatch, caplog + ): + monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true") + adapter = _make_adapter() + adapter._allowed_user_ids = set() + + sync_data = _invite_sync_data( + invite_state={"events": [_member_invite_event(sender="")]} + ) + with caplog.at_level( + logging.WARNING, + logger="plugins.platforms.matrix.adapter", + ): + adapter._schedule_pending_invite_joins(sync_data) + await _drain_invite_tasks(adapter) + + adapter._join_room_by_id.assert_awaited_once_with("!pending_room:example.org") + adapter._record_dm_room.assert_not_awaited() + assert [record.getMessage() for record in caplog.records] == [ + "Matrix: joining direct invite to !pending_room:example.org " + "without recording it in m.direct because the invite state " + "has no inviter" + ]