Skip to content

fix(discord): scope DISCORD_ALLOWED_ROLES to originating guild (#12136, CVSS 8.1)#21241

Merged
teknium1 merged 3 commits into
mainfrom
hermes/hermes-eccefecb
May 7, 2026
Merged

fix(discord): scope DISCORD_ALLOWED_ROLES to originating guild (#12136, CVSS 8.1)#21241
teknium1 merged 3 commits into
mainfrom
hermes/hermes-eccefecb

Conversation

@teknium1

@teknium1 teknium1 commented May 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Scopes DISCORD_ALLOWED_ROLES to 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)

  1. Operator sets DISCORD_ALLOWED_ROLES=<role_id> to authorize moderators of private trusted server B.
  2. Bot also joined a large public server A.
  3. Attacker obtains <role_id> in server A.
  4. Attacker DMs the bot. _is_allowed_user scans 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 — new guild + is_dm kwargs. Role check is strictly guild-scoped: author.roles trusted only when author.guild matches the message's guild, otherwise resolves via message.guild.get_member(uid). Never scans other mutual guilds.
  • DM path: role-based auth disabled by default. Opt-in via discord.dm_role_auth_guild: <guild_id> in config.yaml (not an env var — per repo policy .env is 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.
  • Four _is_allowed_user call 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.pyDEFAULT_CONFIG["discord"]["dm_role_auth_guild"] = "" with comment block.
  • tests/gateway/test_discord_slash_auth.py::_make_interaction fixture: guild SimpleNamespace now has id + get_member(uid)->None so the new guild-scoped fallback path doesn't AttributeError.

Commits

  1. 0xyg3n's original fix (cherry-picked, authorship preserved)_is_allowed_user signature + guild-scoped logic + DM opt-in + 9 regression tests.
  2. Slash-surface sibling-site fix — wires interaction.guild / in_dm through on the slash path so that surface can't bypass the same way. Three new slash-surface regression tests. Fixture update.
  3. Config.yaml routing — removes DISCORD_DM_ROLE_AUTH_GUILD env var, replaces with discord.dm_role_auth_guild in config.yaml, updates tests to patch read_raw_config instead of env vars.

Validation

Before After
DM from user with role in mutual PUBLIC guild authorized (bypass) rejected
DM from user with role in trusted guild, config opt-in set authorized authorized
DM from user with role in trusted guild, config opt-in NOT set authorized rejected (secure default)
Guild-message in trusted guild, user has role there authorized authorized
Guild-message in trusted guild, user's role only in public guild authorized (bypass) rejected
Slash interaction in guild not scoped scoped via interaction.guild
No allowlist set everyone allowed everyone allowed (unchanged)
DISCORD_DM_ROLE_AUTH_GUILD env var (with no config) would have authorized no effect (policy)

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_thread also fails on clean main (pre-existing, unrelated).

Breaking-change note for release notes

Operators currently relying on DISCORD_ALLOWED_ROLES for DM authorization via any mutual guild will need to add discord.dm_role_auth_guild: <trusted_guild_id> to config.yaml to 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.

0xyg3n and others added 3 commits May 7, 2026 05:40
…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.
@teknium1 teknium1 merged commit 80717a1 into main May 7, 2026
9 of 11 checks passed
@teknium1 teknium1 deleted the hermes/hermes-eccefecb branch May 7, 2026 12:51
@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: hermes/hermes-eccefecb vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 7520 on HEAD, 7518 on base (🆕 +2)

🆕 New issues (4):

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/gateway Gateway runner, session dispatch, delivery P1 High — major feature broken, no workaround platform/discord Discord bot adapter type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[SECURITY] DISCORD_ALLOWED_ROLES cross-guild DM bypass (CVSS 8.1)

3 participants