fix(matrix): apply the inviter allowlist to reconciled pending invites
_on_invite only auto-joins a room when the inviter is allow-listed (or GATEWAY_ALLOW_ALL_USERS is set), so a live invite from an arbitrary federated user is rejected. A pending invite that arrives while the gateway is down takes a different path: _schedule_pending_invite_joins reconciles it from rooms.invite in the sync response and scheduled the join unconditionally. An unauthorized invite sent during downtime was therefore auto-joined on restart, bypassing the allowlist. Extract the gate from _on_invite into _is_authorized_inviter and apply it during reconciliation too, reading the inviter from the stripped invite state (the sender of the m.room.member event for our own user, as _extract_invite_dm_signal already does for the DM signal). An inviter that cannot be read from the invite state fails closed, exactly like an empty sender in _on_invite: the invite is skipped with a warning and left pending.
This commit is contained in:
@@ -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", {})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
190
tests/gateway/test_matrix_pending_invite_auth.py
Normal file
190
tests/gateway/test_matrix_pending_invite_auth.py
Normal file
@@ -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"
|
||||
]
|
||||
Reference in New Issue
Block a user