From b38140eb8fc2cfbad388e5dd77ef2523bd198c4f Mon Sep 17 00:00:00 2001 From: soynchux Date: Mon, 18 May 2026 09:17:03 +0300 Subject: [PATCH] fix(gateway): allow chat-scoped telegram auth without sender user_id --- gateway/run.py | 40 +++++- .../gateway/test_unauthorized_dm_behavior.py | 127 ++++++++++++++++++ 2 files changed, 162 insertions(+), 5 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 99e2d9de9f..8024732ff2 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5851,6 +5851,33 @@ class GatewayRunner: return True user_id = source.user_id + + # Telegram (and similar) authorize entire group/forum chats by + # chat ID via TELEGRAM_GROUP_ALLOWED_CHATS / QQ_GROUP_ALLOWED_USERS. + # That allowlist is chat-scoped, so it must work even when + # source.user_id is None — Telegram emits anonymous-admin posts + # and sender_chat traffic in groups with no `from_user`, and an + # operator who explicitly listed the chat expects those to be + # honored. Run this check before the no-user-id guard below so + # documented behavior matches reality + # (website/docs/reference/environment-variables.md, + # website/docs/user-guide/messaging/telegram.md). + if source.chat_type in {"group", "forum"} and source.chat_id: + chat_allowlist_env = { + Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS", + Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS", + }.get(source.platform, "") + if chat_allowlist_env: + raw_chat_allowlist = os.getenv(chat_allowlist_env, "").strip() + if raw_chat_allowlist: + allowed_group_ids = { + cid.strip() + for cid in raw_chat_allowlist.split(",") + if cid.strip() + } + if "*" in allowed_group_ids or source.chat_id in allowed_group_ids: + return True + if not user_id: return False @@ -6197,11 +6224,14 @@ class GatewayRunner: pass elif source.user_id is None: # Messages with no user identity (Telegram service messages, - # channel forwards, anonymous admin actions) cannot be - # authorized — drop silently instead of triggering the pairing - # flow with a None user_id. - logger.debug("Ignoring message with no user_id from %s", source.platform.value) - return None + # channel forwards, anonymous admin posts, sender_chat) can't + # be paired, but they can still be authorized via a + # chat-scoped allowlist (e.g. TELEGRAM_GROUP_ALLOWED_CHATS + # authorizes every member of the listed chat regardless of + # sender). Defer to _is_user_authorized so that path runs. + if not self._is_user_authorized(source): + logger.debug("Ignoring message with no user_id from %s", source.platform.value) + return None elif not self._is_user_authorized(source): logger.warning("Unauthorized user: %s (%s) on %s", source.user_id, source.user_name, source.platform.value) # In DMs: offer pairing code. In groups: silently ignore. diff --git a/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py index bedd3a1f69..0aaad477c3 100644 --- a/tests/gateway/test_unauthorized_dm_behavior.py +++ b/tests/gateway/test_unauthorized_dm_behavior.py @@ -276,6 +276,133 @@ def test_telegram_group_chat_allowlist_authorizes_group_chat_without_user_allowl assert runner._is_user_authorized(source) is True +def test_telegram_group_chat_allowlist_authorizes_anonymous_sender(monkeypatch): + """TELEGRAM_GROUP_ALLOWED_CHATS must authorize chat traffic with no + sender user_id (Telegram anonymous-admin posts, sender_chat). The + docs state the chat allowlist authorizes "every member of that chat, + regardless of sender" — anonymous senders had been silently dropped + despite an explicit chat opt-in. + """ + _clear_auth_env(monkeypatch) + monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_CHATS", "-1001878443972") + + runner, _adapter = _make_runner( + Platform.TELEGRAM, + GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")}), + ) + + source = SessionSource( + platform=Platform.TELEGRAM, + user_id=None, + chat_id="-1001878443972", + user_name=None, + chat_type="group", + ) + + assert runner._is_user_authorized(source) is True + + +def test_telegram_group_chat_allowlist_rejects_anonymous_sender_in_other_chat(monkeypatch): + """Anonymous senders in a chat *not* on the allowlist must still be + rejected — the early no-user-id path must not become an open gate. + """ + _clear_auth_env(monkeypatch) + monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_CHATS", "-1001878443972") + + runner, _adapter = _make_runner( + Platform.TELEGRAM, + GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")}), + ) + + source = SessionSource( + platform=Platform.TELEGRAM, + user_id=None, + chat_id="-1009999999999", + user_name=None, + chat_type="group", + ) + + assert runner._is_user_authorized(source) is False + + +@pytest.mark.asyncio +async def test_handle_message_does_not_drop_anonymous_sender_in_allowlisted_chat(monkeypatch): + """End-to-end: a group message with from_user=None in an allowlisted + chat must reach the dispatch path — not get silently dropped by the + no-user-id guard, and not trigger pairing (anonymous senders can't + be paired anyway). + """ + _clear_auth_env(monkeypatch) + monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_CHATS", "-1001878443972") + + config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")}, + ) + runner, adapter = _make_runner(Platform.TELEGRAM, config) + + # Force _handle_message to bail with a sentinel right after the + # auth gate, so a successful "auth passed" call can be distinguished + # from the buggy "silently dropped" case (which would return None + # before this hook ever runs). + reached_dispatch = MagicMock(side_effect=RuntimeError("reached dispatch")) + runner._session_key_for_source = reached_dispatch + + event = MessageEvent( + text="hi", + message_id="m1", + source=SessionSource( + platform=Platform.TELEGRAM, + user_id=None, + chat_id="-1001878443972", + user_name=None, + chat_type="group", + ), + ) + + with pytest.raises(RuntimeError, match="reached dispatch"): + await runner._handle_message(event) + + reached_dispatch.assert_called_once() + runner.pairing_store.generate_code.assert_not_called() + adapter.send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_handle_message_drops_anonymous_sender_outside_allowlist(monkeypatch): + """Anonymous senders in a chat *not* on the allowlist remain silently + dropped — the fix must not become a backdoor for unauthorized chats. + """ + _clear_auth_env(monkeypatch) + monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_CHATS", "-1001878443972") + + config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")}, + ) + runner, adapter = _make_runner(Platform.TELEGRAM, config) + + must_not_run = MagicMock(side_effect=AssertionError("auth gate did not drop")) + runner._session_key_for_source = must_not_run + + event = MessageEvent( + text="hi", + message_id="m1", + source=SessionSource( + platform=Platform.TELEGRAM, + user_id=None, + chat_id="-1009999999999", + user_name=None, + chat_type="group", + ), + ) + + result = await runner._handle_message(event) + + assert result is None + must_not_run.assert_not_called() + runner.pairing_store.generate_code.assert_not_called() + adapter.send.assert_not_awaited() + + def test_telegram_group_users_legacy_chat_ids_still_authorize(monkeypatch): """Backward-compat: PR #15027 shipped TELEGRAM_GROUP_ALLOWED_USERS as a chat-ID allowlist. PR #17686 renamed it to sender IDs and added