fix(discord): scope DISCORD_ALLOWED_ROLES to originating guild (#12136, CVSS 8.1)#21241
Merged
Conversation
…8.1) The initial DISCORD_ALLOWED_ROLES implementation (#11608, merged from #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: #11608 (initial implementation), #7871 (feature request), #9873 (PR author credit @0xyg3n)
Sibling-site fix: _evaluate_slash_authorization was the fourth _is_allowed_user caller and didn't pass guild/is_dm through, so slash interactions would take the DM branch regardless of whether they came from a guild channel. Now reads interaction.guild + in_dm and forwards. Also updates test_discord_slash_auth fixture (_make_interaction) so the SimpleNamespace guild mock has a get_member(uid)->None method — required by the new guild-scoped fallback path in _is_allowed_user. Tests exercising positive role paths still work via user.roles. Three new regression tests in test_discord_roles_dm_scope: - Slash DM + role in mutual public guild → rejected - Slash in guild B + role only in guild A → rejected - Slash in guild B + role in guild B → allowed (positive control) 368 Discord tests pass. test_discord_free_channel_skips_auto_thread also fails on clean main (pre-existing, unrelated to this fix).
…var) Per repo policy, ~/.hermes/.env is for secrets only. Guild IDs are behavioral configuration, not secrets. Replacing the DISCORD_DM_ROLE_AUTH_GUILD env var from the original fix with discord.dm_role_auth_guild in config.yaml. - New module-level _read_dm_role_auth_guild() helper reads hermes_cli.config.read_raw_config()['discord']['dm_role_auth_guild']. Fails closed on any parse error (safe default = DM role-auth off). - DEFAULT_CONFIG['discord'] gains dm_role_auth_guild: '' with a comment documenting the opt-in. - Tests patch hermes_cli.config.read_raw_config directly (via the _set_dm_role_auth_guild helper) instead of setenv/delenv. 12 tests in test_discord_roles_dm_scope pass; no env var involvement. - Docstring + module docstring + comments updated to reference discord.dm_role_auth_guild. - E2E verified with real imports across 6 scenarios: unset, int, string, garbage, zero, and (crucially) env-var-only-no-config all return None except the valid int/string cases. Env var has zero effect — policy compliance confirmed.
This was referenced May 7, 2026
Contributor
🔎 Lint report:
|
| Rule | Count |
|---|---|
invalid-argument-type |
3 |
unresolved-import |
1 |
First entries
run_agent.py:12318: [invalid-argument-type] invalid-argument-type: Argument to function `len` is incorrect: Expected `Sized`, found `(str & ~AlwaysFalsy) | (dict[Unknown, Unknown] & ~AlwaysFalsy) | (Any & ~AlwaysFalsy) | ... omitted 3 union elements`
run_agent.py:12315: [invalid-argument-type] invalid-argument-type: Argument to function `_is_oauth_token` is incorrect: Expected `str`, found `str | dict[Unknown, Unknown] | Any | ... omitted 3 union elements`
run_agent.py:6454: [invalid-argument-type] invalid-argument-type: Argument to function `build_anthropic_client` is incorrect: Expected `str`, found `str | dict[Unknown, Unknown] | Any | ... omitted 3 union elements`
tests/gateway/test_discord_roles_dm_scope.py:21: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
✅ Fixed issues (3):
| Rule | Count |
|---|---|
invalid-argument-type |
3 |
First entries
run_agent.py:12315: [invalid-argument-type] invalid-argument-type: Argument to function `_is_oauth_token` is incorrect: Expected `str`, found `str | dict[Unknown | str, Unknown | str | dict[str, str]] | Any | ... omitted 3 union elements`
run_agent.py:6454: [invalid-argument-type] invalid-argument-type: Argument to function `build_anthropic_client` is incorrect: Expected `str`, found `str | dict[Unknown | str, Unknown | str | dict[str, str]] | Any | ... omitted 3 union elements`
run_agent.py:12318: [invalid-argument-type] invalid-argument-type: Argument to function `len` is incorrect: Expected `Sized`, found `(str & ~AlwaysFalsy) | (dict[Unknown | str, Unknown | str | dict[str, str]] & ~AlwaysFalsy) | (Any & ~AlwaysFalsy) | ... omitted 3 union elements`
Unchanged: 3945 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
1 task
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Scopes
DISCORD_ALLOWED_ROLESto the originating guild. Closes a cross-guild DM bypass where a role held in any mutual (public) guild authorized DMs intended for a single trusted guild.Closes #12136. Salvages #12135 from @0xyg3n (same contributor who originally implemented the feature in #9873 and discovered the bypass).
Exploit (pre-fix)
DISCORD_ALLOWED_ROLES=<role_id>to authorize moderators of private trusted server B.<role_id>in server A._is_allowed_userscans every mutual guild, finds the role in server A, authorizes the DM.CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N — base 8.1 (High).
Changes
gateway/platforms/discord.py::_is_allowed_user— newguild+is_dmkwargs. Role check is strictly guild-scoped:author.rolestrusted only whenauthor.guildmatches the message's guild, otherwise resolves viamessage.guild.get_member(uid). Never scans other mutual guilds.discord.dm_role_auth_guild: <guild_id>inconfig.yaml(not an env var — per repo policy.envis for secrets only, and guild IDs are behavioral configuration). New module-level_read_dm_role_auth_guild()helper; secure-default / fail-closed on any parse error._is_allowed_usercall sites updated:on_message, voice listen loop,_evaluate_slash_authorization(slash surface — sibling-site widening), and the existing user-allowlist caller inherits the new signature defaults.hermes_cli/config.py—DEFAULT_CONFIG["discord"]["dm_role_auth_guild"] = ""with comment block.tests/gateway/test_discord_slash_auth.py::_make_interactionfixture:guildSimpleNamespace now hasid+get_member(uid)->Noneso the new guild-scoped fallback path doesn't AttributeError.Commits
_is_allowed_usersignature + guild-scoped logic + DM opt-in + 9 regression tests.interaction.guild/in_dmthrough on the slash path so that surface can't bypass the same way. Three new slash-surface regression tests. Fixture update.DISCORD_DM_ROLE_AUTH_GUILDenv var, replaces withdiscord.dm_role_auth_guildin config.yaml, updates tests to patchread_raw_configinstead of env vars.Validation
interaction.guildDISCORD_DM_ROLE_AUTH_GUILDenv var (with no config)368 Discord tests pass. E2E-verified with real imports across 6 config-read scenarios (unset / int / string / garbage / zero / env-var-only) — env var has zero effect, all invalid shapes fail closed.
test_discord_free_channel_skips_auto_threadalso fails on clean main (pre-existing, unrelated).Breaking-change note for release notes
Operators currently relying on
DISCORD_ALLOWED_ROLESfor DM authorization via any mutual guild will need to adddiscord.dm_role_auth_guild: <trusted_guild_id>toconfig.yamlto restore that behavior, scoped to a single guild.Credit
Fix authored by @0xyg3n, who also authored the original feature and discovered the bypass. Preserving commit authorship via rebase-merge.