fix(matrix): close 'hall of mirrors' pairing + echo loop (#15763) (#16374)

Harden the Matrix adapter's sender-drop guards so bot-self events and
appservice/bridge identities never reach the gateway's pairing flow or
the agent loop.

Two filters, applied as early as possible in _on_room_message (and
_on_reaction for the self-filter):

1. _is_self_sender(sender) — case-insensitive + whitespace-trimmed
   equality with self._user_id.  When self._user_id is still empty
   (whoami has not resolved, or login failed), returns True
   defensively: an unidentified bot dropping its own events is always
   preferable to falling into an echo loop.  The previous byte-for-byte
   equality check let differently-cased copies of the bot's MXID slip
   through, and an unresolved self-ID silently disabled the guard.

2. _is_system_or_bridge_sender(sender) — drops appservice namespace
   puppets (conventional @_bridge_...:server form) and malformed
   senders with an empty localpart.  These identities used to fall
   through to the gateway's unauthorized-user path, trigger a pairing
   code, and — once an operator approved the bridge — every outbound
   message the bridge relayed would loop back as an authorized user
   message.  This was the root of the 'hall of mirrors' symptom.

Fixes #15763

Test plan
---------
scripts/run_tests.sh tests/gateway/test_matrix.py
scripts/run_tests.sh tests/gateway/test_matrix_mention.py tests/gateway/test_matrix_voice.py
All 182 tests pass.  14 new regression tests cover exact / case-insensitive
/ whitespace / unresolved-self-id matches, bridge prefix detection, empty
sender, and the full _on_room_message drop path.
This commit is contained in:
Teknium
2026-04-26 21:50:28 -07:00
committed by GitHub
parent 4a2ee6c162
commit af3d5150c1
2 changed files with 216 additions and 3 deletions

View File

@@ -1956,3 +1956,146 @@ class TestMatrixPresence:
self.adapter._client = None
result = await self.adapter.set_presence("online")
assert result is False
# ---------------------------------------------------------------------------
# Self / bridge / system sender filtering — regression coverage for #15763
# ("Hall of Mirrors": recursive pairing / echo loops triggered by bridge
# or bot-self senders bypassing the early-drop guard in _on_room_message).
# ---------------------------------------------------------------------------
class TestMatrixSelfSenderFilter:
def setup_method(self):
self.adapter = _make_adapter()
def test_exact_match_is_self(self):
self.adapter._user_id = "@bot:example.org"
assert self.adapter._is_self_sender("@bot:example.org") is True
def test_case_insensitive_match_is_self(self):
# Some homeservers canonicalize the localpart differently at
# different API surfaces — a case-sensitive equality check lets
# the bot's own sender through and triggers the pairing / echo
# loop in #15763.
self.adapter._user_id = "@Bot:Example.ORG"
assert self.adapter._is_self_sender("@bot:example.org") is True
assert self.adapter._is_self_sender("@BOT:EXAMPLE.ORG") is True
def test_whitespace_trimmed(self):
self.adapter._user_id = "@bot:example.org"
assert self.adapter._is_self_sender(" @bot:example.org ") is True
def test_different_user_is_not_self(self):
self.adapter._user_id = "@bot:example.org"
assert self.adapter._is_self_sender("@alice:example.org") is False
def test_empty_user_id_is_treated_as_self(self):
# If whoami hasn't resolved yet (or login failed), we cannot
# prove a sender is NOT us. Defensively drop rather than leak
# our own outbound traffic into the agent loop.
self.adapter._user_id = ""
assert self.adapter._is_self_sender("@alice:example.org") is True
assert self.adapter._is_self_sender("") is True
class TestMatrixSystemBridgeFilter:
def setup_method(self):
self.adapter = _make_adapter()
def test_appservice_underscore_prefix_is_bridge(self):
# Conventional appservice namespace puppets
assert self.adapter._is_system_or_bridge_sender(
"@_telegram_12345:bridge.example.org"
) is True
assert self.adapter._is_system_or_bridge_sender(
"@_discord_999:example.org"
) is True
assert self.adapter._is_system_or_bridge_sender(
"@_slackbridge_puppet:example.org"
) is True
def test_empty_localpart_is_system(self):
assert self.adapter._is_system_or_bridge_sender("@:server.example") is True
def test_empty_sender_is_system(self):
assert self.adapter._is_system_or_bridge_sender("") is True
assert self.adapter._is_system_or_bridge_sender(" ") is True
def test_regular_user_is_not_bridge(self):
assert self.adapter._is_system_or_bridge_sender(
"@alice:example.org"
) is False
# A user whose localpart merely CONTAINS an underscore is not a
# bridge — the convention is a LEADING underscore.
assert self.adapter._is_system_or_bridge_sender(
"@alice_smith:example.org"
) is False
def test_bot_account_is_not_bridge(self):
# The Hermes bot itself (no leading underscore) must not be
# classified as a bridge — that filter is a pairing guard, not
# a self-filter.
assert self.adapter._is_system_or_bridge_sender(
"@daemon:nerdworks.casa"
) is False
class TestMatrixOnRoomMessageFilter:
"""End-to-end coverage of _on_room_message drop conditions."""
def setup_method(self):
self.adapter = _make_adapter()
self.adapter._user_id = "@bot:example.org"
self.adapter._startup_ts = 0.0 # accept any event_ts
self.adapter._handle_text_message = AsyncMock()
self.adapter._handle_media_message = AsyncMock()
@staticmethod
def _mk_event(sender, body="hi", msgtype="m.text", event_id=None, ts=None):
import time as _t
ev = MagicMock()
ev.room_id = "!room:example.org"
ev.sender = sender
ev.event_id = event_id or f"$evt-{sender}-{body}"
ev.timestamp = int((ts or _t.time()) * 1000)
ev.server_timestamp = ev.timestamp
ev.content = {"msgtype": msgtype, "body": body}
return ev
@pytest.mark.asyncio
async def test_own_sender_case_insensitive_dropped(self):
# Simulate whoami returning a differently-cased copy of our MXID.
self.adapter._user_id = "@Bot:Example.ORG"
ev = self._mk_event(sender="@bot:example.org")
await self.adapter._on_room_message(ev)
self.adapter._handle_text_message.assert_not_called()
@pytest.mark.asyncio
async def test_bridge_sender_dropped_before_pairing(self):
ev = self._mk_event(sender="@_telegram_12345:bridge.example.org")
await self.adapter._on_room_message(ev)
# Bridge / appservice identities must never flow through to the
# gateway — otherwise they trigger pairing (#15763).
self.adapter._handle_text_message.assert_not_called()
@pytest.mark.asyncio
async def test_empty_sender_dropped(self):
ev = self._mk_event(sender="")
await self.adapter._on_room_message(ev)
self.adapter._handle_text_message.assert_not_called()
@pytest.mark.asyncio
async def test_self_with_unresolved_user_id_dropped(self):
# whoami has not resolved yet → user_id empty → drop ALL traffic
# defensively rather than risk echoing our own outbound messages.
self.adapter._user_id = ""
ev = self._mk_event(sender="@alice:example.org")
await self.adapter._on_room_message(ev)
self.adapter._handle_text_message.assert_not_called()
@pytest.mark.asyncio
async def test_regular_user_reaches_text_handler(self):
ev = self._mk_event(sender="@alice:example.org", body="hello bot")
await self.adapter._on_room_message(ev)
self.adapter._handle_text_message.assert_awaited_once()