/ tests / gateway / test_discord_bot_auth_bypass.py
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