fix(discord): scope DISCORD_ALLOWED_ROLES to originating guild — cross-guild DM bypass (CVSS 8.1)#12135
Conversation
…8.1) The initial DISCORD_ALLOWED_ROLES implementation (NousResearch#11608, merged from NousResearch#9873) scans every mutual guild when resolving a user's roles. This allows a cross-guild DM bypass: 1. Bot is in both public server A and private server B. 2. User holds the allowed role in server A only. 3. User DMs the bot. The role check finds the role in A and authorizes the DM, granting access as if the user were trusted in server B. Fix: - DMs (no guild context) disable role-based auth by default. Opt-in via DISCORD_DM_ROLE_AUTH_GUILD=<guild_id> restricts role lookup to one explicitly-trusted guild. - Guild messages check roles only in the originating guild (message.guild), never in other mutual guilds. - Reject cached author.roles when the Member came from a different guild than the current message. Backwards compatibility: - DISCORD_ALLOWED_USERS behavior is unchanged (still works in both DMs and guild messages). - Deployments that rely on roles in guild channels continue to work; role checks are now strictly scoped to that guild. - Deployments that intentionally want role-based DM auth can opt into a single trusted guild via DISCORD_DM_ROLE_AUTH_GUILD. Tests: 9 new regression guards in tests/gateway/test_discord_roles_dm_scope.py covering the bypass path, the opt-in path, cross-guild guild-message bypass, and backwards-compat user-ID paths. 47/47 discord-auth tests pass. Refs: NousResearch#11608 (initial implementation), NousResearch#7871 (feature request), NousResearch#9873 (PR author credit @0xyg3n)
|
CI note — the 4
This PR only touches |
|
Merged via salvage PR #21241 (commit ef1e565 on main, authorship preserved). Two small follow-ups landed on top of your fix in the same PR: a sibling-site extension to Thanks for the whole package on this one: you wrote the original feature, found the hole, filed the CVSS-scored report, and shipped a clean fix PR. That's exactly the kind of contribution loop this project depends on. |
Summary
Security fix:
DISCORD_ALLOWED_ROLESis not guild-scoped, causing a cross-guild DM bypass.The role allowlist implementation merged in #11608 (from #9873, authored by @0xyg3n) iterates every mutual guild the bot is in when resolving a user's roles. If a user holds a configured role in any server the bot is also in, they are authorized — including in DMs, where there is no guild context to anchor the check.
This violates the principle of least privilege expressed by the original feature request (#7871), where moderators wanted role-scoped authorization for their specific server, not cross-guild access to every Hermes-backed bot deployment they happen to share a guild with.
Severity
CVSS:3.1 / AV:N / AC:L / PR:L / UI:N / S:C / C:H / I:H / A:N — Base score 8.1 (High)
Exploit scenario:
DISCORD_ALLOWED_ROLES=111222333to authorize moderators of a private trusted server B.111222333(easily done if the role is anything non-trivially gated, or through a role ID collision they discover).self._client.guilds, finds the role in server A, and authorizes the DM.The same cross-guild leakage applies to guild messages: a user with the allowed role in one mutual guild can speak authorized in a channel of a different mutual guild if that second guild has no allowlist of its own beyond
DISCORD_ALLOWED_ROLES.Root cause
gateway/platforms/discord.py :: _is_allowed_user(as merged in #11608):Two design flaws:
_is_allowed_user(user_id, author=None)has no way to know where the message came from, so it cannot scope role checks to that guild.message.guild is None, but the fallback iterates all ofself._client.guilds, which is exactly the cross-guild leak.Fix
_is_allowed_user(user_id, author=None, *, guild=None, is_dm=False).guildonly. Author-object.rolesare only trusted ifauthor.guild.id == guild.id. Fallback resolves the Member inguild.get_member(...)— never in other mutual guilds.DISCORD_DM_ROLE_AUTH_GUILD=<guild_id>, which restricts the role lookup to a single explicitly-trusted guild.on_message, voice listen loop) updated to passguild+is_dm.Backwards compatibility
DISCORD_ALLOWED_USERSunchanged — still authorizes in both DMs and guild channels.DISCORD_DM_ROLE_AUTH_GUILDopt-in. This is a behavior change but a security-positive one: deployments that relied on the cross-guild path were exposed to the bypass.Tests
9 new regression guards in
tests/gateway/test_discord_roles_dm_scope.py:test_dm_rejects_role_held_in_other_guild— exploit path MUST rejecttest_dm_role_auth_requires_explicit_guild_optin— opt-in workstest_dm_role_auth_optin_rejects_when_not_member— opt-in scope is correcttest_guild_message_role_check_scoped_to_originating_guild— cross-guild guild-message bypass rejectedtest_guild_message_role_check_allows_when_role_in_same_guild— positive pathtest_guild_message_rejects_author_roles_from_different_guild— cached Member from wrong guild not trustedtest_user_id_allowlist_works_in_dm— backwards compattest_user_id_allowlist_works_in_guild— backwards compattest_empty_allowlists_allow_everyone— backwards compatAll 47 Discord-auth tests pass (new + existing
test_discord_bot_auth_bypass.py,test_unauthorized_dm_behavior.py,test_discord_allowed_mentions.py). Zero regressions.References
test_discord_bot_auth_bypass.py(DISCORD_ALLOW_BOTS has no effect without also adding the bot to DISCORD_ALLOWED_USERS #4466)Deployment guidance
Operators currently using
DISCORD_ALLOWED_ROLES:DISCORD_DM_ROLE_AUTH_GUILD=<your_trusted_guild_id>to restore DM authorization against that one guild.DISCORD_ALLOWED_USERSwith explicit user IDs.cc @teknium1 — this fixes a security issue in the implementation you merged.