test_discord_bot_auth_bypass.py
1 """Regression guard for #4466: DISCORD_ALLOW_BOTS works without DISCORD_ALLOWED_USERS. 2 3 The bug had two sequential gates both rejecting bot messages: 4 5 Gate 1 — `on_message` in gateway/platforms/discord.py ran the user-allowlist 6 check BEFORE the bot filter, so bot senders were dropped with a warning 7 before the DISCORD_ALLOW_BOTS policy was ever evaluated. 8 9 Gate 2 — `_is_user_authorized` in gateway/run.py rejected bots at the 10 gateway level even if they somehow reached that layer. 11 12 These tests assert both gates now pass a bot message through when 13 DISCORD_ALLOW_BOTS permits it AND no user allowlist entry exists. 14 """ 15 16 import os 17 from types import SimpleNamespace 18 from unittest.mock import patch 19 20 import pytest 21 22 from gateway.session import Platform, SessionSource 23 24 25 @pytest.fixture(autouse=True) 26 def _isolate_discord_env(monkeypatch): 27 """Make every test start with a clean Discord env so prior tests in the 28 session (or CI setups) can't leak DISCORD_ALLOWED_ROLES / DISCORD_ALLOWED_USERS 29 / DISCORD_ALLOW_BOTS and silently flip the auth result. 30 """ 31 for var in ( 32 "DISCORD_ALLOW_BOTS", 33 "DISCORD_ALLOWED_USERS", 34 "DISCORD_ALLOWED_ROLES", 35 "DISCORD_ALLOW_ALL_USERS", 36 "GATEWAY_ALLOW_ALL_USERS", 37 "GATEWAY_ALLOWED_USERS", 38 ): 39 monkeypatch.delenv(var, raising=False) 40 41 42 # ----------------------------------------------------------------------------- 43 # Gate 2: _is_user_authorized bypasses allowlist for permitted bots 44 # ----------------------------------------------------------------------------- 45 46 47 def _make_bare_runner(): 48 """Build a GatewayRunner skeleton with just enough wiring for the auth test. 49 50 Uses ``object.__new__`` to skip the heavy __init__ — many gateway tests 51 use this pattern (see AGENTS.md pitfall #17). 52 """ 53 from gateway.run import GatewayRunner 54 runner = object.__new__(GatewayRunner) 55 # _is_user_authorized reads self.pairing_store.is_approved(...) before 56 # any allowlist check succeeds; stub it to never approve so we exercise 57 # the real allowlist path. 58 runner.pairing_store = SimpleNamespace(is_approved=lambda *_a, **_kw: False) 59 return runner 60 61 62 def _make_discord_bot_source(bot_id: str = "999888777"): 63 return SessionSource( 64 platform=Platform.DISCORD, 65 chat_id="123", 66 chat_type="channel", 67 user_id=bot_id, 68 user_name="SomeBot", 69 is_bot=True, 70 ) 71 72 73 def _make_discord_human_source(user_id: str = "100200300"): 74 return SessionSource( 75 platform=Platform.DISCORD, 76 chat_id="123", 77 chat_type="channel", 78 user_id=user_id, 79 user_name="SomeHuman", 80 is_bot=False, 81 ) 82 83 84 def test_discord_bot_authorized_when_allow_bots_mentions(monkeypatch): 85 """DISCORD_ALLOW_BOTS=mentions must authorize a bot sender even when 86 DISCORD_ALLOWED_USERS is set and the bot's ID is NOT in it. 87 88 This is the exact scenario from #4466 — a Cloudflare Worker webhook 89 posts Notion events to Discord, the Hermes bot gets @mentioned, and 90 the webhook's bot ID is not (and shouldn't be) on the human 91 allowlist. 92 """ 93 runner = _make_bare_runner() 94 95 monkeypatch.setenv("DISCORD_ALLOW_BOTS", "mentions") 96 monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300") # human-only allowlist 97 98 source = _make_discord_bot_source(bot_id="999888777") 99 assert runner._is_user_authorized(source) is True 100 101 102 def test_discord_bot_authorized_when_allow_bots_all(monkeypatch): 103 """DISCORD_ALLOW_BOTS=all is a superset of =mentions — should also bypass.""" 104 runner = _make_bare_runner() 105 106 monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") 107 monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300") 108 109 source = _make_discord_bot_source() 110 assert runner._is_user_authorized(source) is True 111 112 113 def test_discord_bot_NOT_authorized_when_allow_bots_none(monkeypatch): 114 """DISCORD_ALLOW_BOTS=none (default) must still reject bots that aren't 115 in DISCORD_ALLOWED_USERS — preserves the original security behavior. 116 """ 117 runner = _make_bare_runner() 118 119 monkeypatch.setenv("DISCORD_ALLOW_BOTS", "none") 120 monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300") 121 122 source = _make_discord_bot_source(bot_id="999888777") 123 assert runner._is_user_authorized(source) is False 124 125 126 def test_discord_bot_NOT_authorized_when_allow_bots_unset(monkeypatch): 127 """Unset DISCORD_ALLOW_BOTS must behave like 'none'.""" 128 runner = _make_bare_runner() 129 130 monkeypatch.delenv("DISCORD_ALLOW_BOTS", raising=False) 131 monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300") 132 133 source = _make_discord_bot_source(bot_id="999888777") 134 assert runner._is_user_authorized(source) is False 135 136 137 def test_discord_human_still_checked_against_allowlist_when_bot_policy_set(monkeypatch): 138 """DISCORD_ALLOW_BOTS=all must NOT open the gate for humans — they 139 still need to be in DISCORD_ALLOWED_USERS (or a pairing approval). 140 """ 141 runner = _make_bare_runner() 142 143 monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") 144 monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300") 145 146 # Human NOT on the allowlist → must be rejected. 147 source = _make_discord_human_source(user_id="999999999") 148 assert runner._is_user_authorized(source) is False 149 150 # Human ON the allowlist → accepted. 151 source_allowed = _make_discord_human_source(user_id="100200300") 152 assert runner._is_user_authorized(source_allowed) is True 153 154 155 def test_bot_bypass_does_not_leak_to_other_platforms(monkeypatch): 156 """The is_bot bypass is Discord-specific — a Telegram bot source with 157 is_bot=True must NOT be authorized just because DISCORD_ALLOW_BOTS=all. 158 """ 159 runner = _make_bare_runner() 160 161 monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") 162 monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "100200300") 163 164 telegram_bot = SessionSource( 165 platform=Platform.TELEGRAM, 166 chat_id="123", 167 chat_type="channel", 168 user_id="999888777", 169 is_bot=True, 170 ) 171 assert runner._is_user_authorized(telegram_bot) is False 172 173 174 # ----------------------------------------------------------------------------- 175 # DISCORD_ALLOWED_ROLES gateway-layer bypass (#7871) 176 # ----------------------------------------------------------------------------- 177 178 179 def test_discord_role_config_bypasses_gateway_allowlist(monkeypatch): 180 """When DISCORD_ALLOWED_ROLES is set, _is_user_authorized must trust 181 the adapter's pre-filter and authorize. Without this, role-only setups 182 (DISCORD_ALLOWED_ROLES populated, DISCORD_ALLOWED_USERS empty) would 183 hit the 'no allowlists configured' branch and get rejected. 184 """ 185 runner = _make_bare_runner() 186 187 monkeypatch.setenv("DISCORD_ALLOWED_ROLES", "1493705176387948674") 188 # Note: DISCORD_ALLOWED_USERS is NOT set — the entire point. 189 190 source = _make_discord_human_source(user_id="999888777") 191 assert runner._is_user_authorized(source) is True 192 193 194 def test_discord_role_config_still_authorizes_alongside_users(monkeypatch): 195 """Sanity: setting both DISCORD_ALLOWED_ROLES and DISCORD_ALLOWED_USERS 196 doesn't break the user-id path. Users in the allowlist should still be 197 authorized even if they don't have a role. (OR semantics.) 198 """ 199 runner = _make_bare_runner() 200 201 monkeypatch.setenv("DISCORD_ALLOWED_ROLES", "1493705176387948674") 202 monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300") 203 204 # User on the user allowlist, no role → still authorized at gateway 205 # level via the role bypass (adapter already approved them). 206 source = _make_discord_human_source(user_id="100200300") 207 assert runner._is_user_authorized(source) is True 208 209 210 def test_discord_role_bypass_does_not_leak_to_other_platforms(monkeypatch): 211 """DISCORD_ALLOWED_ROLES must only affect Discord. Setting it should 212 not suddenly start authorizing Telegram users whose platform has its 213 own empty allowlist. 214 """ 215 runner = _make_bare_runner() 216 217 monkeypatch.setenv("DISCORD_ALLOWED_ROLES", "1493705176387948674") 218 # Telegram has its own empty allowlist and no allow-all flag. 219 220 telegram_user = SessionSource( 221 platform=Platform.TELEGRAM, 222 chat_id="123", 223 chat_type="channel", 224 user_id="999888777", 225 ) 226 assert runner._is_user_authorized(telegram_user) is False