diff --git a/gateway/config.py b/gateway/config.py index da9830fcf2a..cede57f75b9 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -714,11 +714,21 @@ def load_gateway_config() -> GatewayConfig: os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower() if "proxy_url" in telegram_cfg and not os.getenv("TELEGRAM_PROXY"): os.environ["TELEGRAM_PROXY"] = str(telegram_cfg["proxy_url"]).strip() - if "group_allowed_chats" in telegram_cfg and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"): - gac = telegram_cfg["group_allowed_chats"] - if isinstance(gac, list): - gac = ",".join(str(v) for v in gac) - os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(gac) + allowed_users = telegram_cfg.get("allow_from") + if allowed_users is not None and not os.getenv("TELEGRAM_ALLOWED_USERS"): + if isinstance(allowed_users, list): + allowed_users = ",".join(str(v) for v in allowed_users) + os.environ["TELEGRAM_ALLOWED_USERS"] = str(allowed_users) + group_allowed_users = telegram_cfg.get("group_allow_from") + if group_allowed_users is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"): + if isinstance(group_allowed_users, list): + group_allowed_users = ",".join(str(v) for v in group_allowed_users) + os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(group_allowed_users) + group_allowed_chats = telegram_cfg.get("group_allowed_chats") + if group_allowed_chats is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS"): + if isinstance(group_allowed_chats, list): + group_allowed_chats = ",".join(str(v) for v in group_allowed_chats) + os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats) if "disable_link_previews" in telegram_cfg: plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {}) if not isinstance(plat_data, dict): diff --git a/gateway/run.py b/gateway/run.py index bffd25a0860..b94f17c2980 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2345,6 +2345,8 @@ class GatewayRunner: for v in ("TELEGRAM_ALLOWED_USERS", "DISCORD_ALLOWED_USERS", "WHATSAPP_ALLOWED_USERS", "SLACK_ALLOWED_USERS", "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS", + "TELEGRAM_GROUP_ALLOWED_USERS", + "TELEGRAM_GROUP_ALLOWED_CHATS", "EMAIL_ALLOWED_USERS", "SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS", "MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS", @@ -3411,8 +3413,11 @@ class GatewayRunner: Platform.QQBOT: "QQ_ALLOWED_USERS", Platform.YUANBAO: "YUANBAO_ALLOWED_USERS", } - platform_group_env_map = { + platform_group_user_env_map = { Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS", + } + platform_group_chat_env_map = { + Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS", Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS", } platform_allow_all_map = { @@ -3469,27 +3474,36 @@ class GatewayRunner: # Check platform-specific and global allowlists platform_allowlist = os.getenv(platform_env_map.get(source.platform, ""), "").strip() - group_allowlist = "" + group_user_allowlist = "" + group_chat_allowlist = "" if source.chat_type in {"group", "forum"}: - group_allowlist = os.getenv(platform_group_env_map.get(source.platform, ""), "").strip() + group_user_allowlist = os.getenv(platform_group_user_env_map.get(source.platform, ""), "").strip() + group_chat_allowlist = os.getenv(platform_group_chat_env_map.get(source.platform, ""), "").strip() global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip() - if not platform_allowlist and not group_allowlist and not global_allowlist: + if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist: # No allowlists configured -- check global allow-all flag return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") - # Some platforms authorize group traffic by chat ID rather than sender ID. - if group_allowlist and source.chat_type in {"group", "forum"} and source.chat_id: + # Telegram can optionally authorize group traffic by chat ID. + # Keep this separate from TELEGRAM_GROUP_ALLOWED_USERS, which gates + # the sender user ID for group/forum messages. + if group_chat_allowlist and source.chat_type in {"group", "forum"} and source.chat_id: allowed_group_ids = { - chat_id.strip() for chat_id in group_allowlist.split(",") if chat_id.strip() + chat_id.strip() for chat_id in group_chat_allowlist.split(",") if chat_id.strip() } if "*" in allowed_group_ids or source.chat_id in allowed_group_ids: return True - # Check if user is in any allowlist + # Check if user is in any allowlist. In group/forum chats, + # TELEGRAM_GROUP_ALLOWED_USERS is the scoped allowlist and should not + # imply DM access; TELEGRAM_ALLOWED_USERS remains the platform-wide + # allowlist and still works everywhere for backward compatibility. allowed_ids = set() if platform_allowlist: allowed_ids.update(uid.strip() for uid in platform_allowlist.split(",") if uid.strip()) + if group_user_allowlist: + allowed_ids.update(uid.strip() for uid in group_user_allowlist.split(",") if uid.strip()) if global_allowlist: allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip()) @@ -3523,10 +3537,12 @@ class GatewayRunner: Resolution order: 1. Explicit per-platform ``unauthorized_dm_behavior`` in config — always wins. 2. Explicit global ``unauthorized_dm_behavior`` in config — wins when no per-platform. - 3. When an allowlist (``PLATFORM_ALLOWED_USERS`` or ``GATEWAY_ALLOWED_USERS``) is - configured, default to ``"ignore"`` — the allowlist signals that the owner has - deliberately restricted access; spamming unknown contacts with pairing codes - is both noisy and a potential info-leak. (#9337) + 3. When an allowlist (``PLATFORM_ALLOWED_USERS``, + ``PLATFORM_GROUP_ALLOWED_USERS`` / ``PLATFORM_GROUP_ALLOWED_CHATS``, + or ``GATEWAY_ALLOWED_USERS``) is configured, default to ``"ignore"`` — + the allowlist signals that the owner has deliberately restricted + access; spamming unknown contacts with pairing codes is both noisy + and a potential info-leak. (#9337) 4. No allowlist and no explicit config → ``"pair"`` (open-gateway default). """ config = getattr(self, "config", None) @@ -3565,8 +3581,18 @@ class GatewayRunner: Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", Platform.QQBOT: "QQ_ALLOWED_USERS", } + platform_group_env_map = { + Platform.TELEGRAM: ( + "TELEGRAM_GROUP_ALLOWED_USERS", + "TELEGRAM_GROUP_ALLOWED_CHATS", + ), + Platform.QQBOT: ("QQ_GROUP_ALLOWED_USERS",), + } if os.getenv(platform_env_map.get(platform, ""), "").strip(): return "ignore" + for env_key in platform_group_env_map.get(platform, ()): + if os.getenv(env_key, "").strip(): + return "ignore" if os.getenv("GATEWAY_ALLOWED_USERS", "").strip(): return "ignore" diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index ababe5ec617..a560d6cdd6e 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -5,7 +5,14 @@ from unittest.mock import AsyncMock from gateway.config import Platform, PlatformConfig, load_gateway_config -def _make_adapter(require_mention=None, free_response_chats=None, mention_patterns=None, ignored_threads=None): +def _make_adapter( + require_mention=None, + free_response_chats=None, + mention_patterns=None, + ignored_threads=None, + allow_from=None, + group_allow_from=None, +): from gateway.platforms.telegram import TelegramAdapter extra = {} @@ -17,6 +24,10 @@ def _make_adapter(require_mention=None, free_response_chats=None, mention_patter extra["mention_patterns"] = mention_patterns if ignored_threads is not None: extra["ignored_threads"] = ignored_threads + if allow_from is not None: + extra["allow_from"] = allow_from + if group_allow_from is not None: + extra["group_allow_from"] = group_allow_from adapter = object.__new__(TelegramAdapter) adapter.platform = Platform.TELEGRAM @@ -34,6 +45,7 @@ def _group_message( text="hello", *, chat_id=-100, + from_user_id=111, thread_id=None, reply_to_bot=False, entities=None, @@ -50,10 +62,24 @@ def _group_message( caption_entities=caption_entities or [], message_thread_id=thread_id, chat=SimpleNamespace(id=chat_id, type="group"), + from_user=SimpleNamespace(id=from_user_id), reply_to_message=reply_to_message, ) +def _dm_message(text="hello", *, from_user_id=111): + return SimpleNamespace( + text=text, + caption=None, + entities=[], + caption_entities=[], + message_thread_id=None, + chat=SimpleNamespace(id=from_user_id, type="private"), + from_user=SimpleNamespace(id=from_user_id), + reply_to_message=None, + ) + + def _mention_entity(text, mention="@hermes_bot"): offset = text.index(mention) return SimpleNamespace(type="mention", offset=offset, length=len(mention)) @@ -173,6 +199,68 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path): assert __import__("os").environ["TELEGRAM_FREE_RESPONSE_CHATS"] == "-123" +def test_config_bridges_telegram_user_allowlists(monkeypatch, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "telegram:\n" + " allow_from:\n" + " - \"111\"\n" + " - \"222\"\n" + " group_allow_from:\n" + " - \"333\"\n" + " group_allowed_chats:\n" + " - \"-100\"\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("TELEGRAM_ALLOWED_USERS", raising=False) + monkeypatch.delenv("TELEGRAM_GROUP_ALLOWED_USERS", raising=False) + monkeypatch.delenv("TELEGRAM_GROUP_ALLOWED_CHATS", raising=False) + + config = load_gateway_config() + + assert config is not None + assert __import__("os").environ["TELEGRAM_ALLOWED_USERS"] == "111,222" + assert __import__("os").environ["TELEGRAM_GROUP_ALLOWED_USERS"] == "333" + assert __import__("os").environ["TELEGRAM_GROUP_ALLOWED_CHATS"] == "-100" + + +def test_config_env_overrides_telegram_user_allowlists(monkeypatch, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "telegram:\n" + " allow_from: \"111\"\n" + " group_allow_from: \"222\"\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "999") + monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_USERS", "888") + + config = load_gateway_config() + + assert config is not None + assert __import__("os").environ["TELEGRAM_ALLOWED_USERS"] == "999" + assert __import__("os").environ["TELEGRAM_GROUP_ALLOWED_USERS"] == "888" + + +def test_dm_allow_from_is_enforced_by_gateway_authorization_not_trigger_gate(): + adapter = _make_adapter(allow_from=["111", "222"]) + + assert adapter._should_process_message(_dm_message("hello", from_user_id=111)) is True + assert adapter._should_process_message(_dm_message("hello", from_user_id=333)) is True + + +def test_group_allow_from_is_enforced_by_gateway_authorization_not_trigger_gate(): + adapter = _make_adapter(group_allow_from=["111"]) + + assert adapter._should_process_message(_group_message("hello", from_user_id=333)) is True + + def test_config_bridges_telegram_ignored_threads(monkeypatch, tmp_path): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() diff --git a/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py index 9571f3f4e4d..847648377a1 100644 --- a/tests/gateway/test_unauthorized_dm_behavior.py +++ b/tests/gateway/test_unauthorized_dm_behavior.py @@ -16,6 +16,8 @@ def _clear_auth_env(monkeypatch) -> None: "WHATSAPP_ALLOWED_USERS", "SLACK_ALLOWED_USERS", "SIGNAL_ALLOWED_USERS", + "SIGNAL_GROUP_ALLOWED_USERS", + "TELEGRAM_GROUP_ALLOWED_CHATS", "EMAIL_ALLOWED_USERS", "SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS", @@ -178,9 +180,85 @@ def test_qq_group_allowlist_does_not_authorize_other_groups(monkeypatch): assert runner._is_user_authorized(source) is False -def test_telegram_group_allowlist_authorizes_forum_chat_without_user_allowlist(monkeypatch): +def test_telegram_group_user_allowlist_authorizes_forum_sender_without_dm_allowlist(monkeypatch): _clear_auth_env(monkeypatch) - monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_USERS", "-1001878443972") + monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_USERS", "999") + + runner, _adapter = _make_runner( + Platform.TELEGRAM, + GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")}), + ) + source = SessionSource( + platform=Platform.TELEGRAM, + user_id="999", + chat_id="-1001878443972", + user_name="tester", + chat_type="forum", + ) + + assert runner._is_user_authorized(source) is True + + +def test_telegram_group_user_allowlist_rejects_other_senders(monkeypatch): + _clear_auth_env(monkeypatch) + monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_USERS", "999") + + runner, _adapter = _make_runner( + Platform.TELEGRAM, + GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")}), + ) + source = SessionSource( + platform=Platform.TELEGRAM, + user_id="123", + chat_id="-1001878443972", + user_name="tester", + chat_type="group", + ) + + assert runner._is_user_authorized(source) is False + + +def test_telegram_group_user_allowlist_wildcard_authorizes_any_sender(monkeypatch): + _clear_auth_env(monkeypatch) + monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_USERS", "*") + + runner, _adapter = _make_runner( + Platform.TELEGRAM, + GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")}), + ) + source = SessionSource( + platform=Platform.TELEGRAM, + user_id="123", + chat_id="-1001878443972", + user_name="tester", + chat_type="group", + ) + + assert runner._is_user_authorized(source) is True + + +def test_telegram_group_user_allowlist_does_not_authorize_dms(monkeypatch): + _clear_auth_env(monkeypatch) + monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_USERS", "999") + + runner, _adapter = _make_runner( + Platform.TELEGRAM, + GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")}), + ) + source = SessionSource( + platform=Platform.TELEGRAM, + user_id="999", + chat_id="999", + user_name="tester", + chat_type="dm", + ) + + assert runner._is_user_authorized(source) is False + + +def test_telegram_group_chat_allowlist_authorizes_group_chat_without_user_allowlist(monkeypatch): + _clear_auth_env(monkeypatch) + monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_CHATS", "-1001878443972") runner, _adapter = _make_runner( Platform.TELEGRAM,