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:
Iain Lane
2026-08-15 21:06:10 +01:00
committed by kshitij
parent 0ce3e7b12b
commit 439f2b1cf6
3 changed files with 222 additions and 24 deletions

View File

@@ -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", {})

View File

@@ -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)

View 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"
]