Skip to content

fix(discord): scope DISCORD_ALLOWED_ROLES to originating guild — cross-guild DM bypass (CVSS 8.1)#12135

Closed
0xyg3n wants to merge 1 commit into
NousResearch:mainfrom
0xyg3n:0xyg3n/discord-roles-dm-bypass-fix
Closed

fix(discord): scope DISCORD_ALLOWED_ROLES to originating guild — cross-guild DM bypass (CVSS 8.1)#12135
0xyg3n wants to merge 1 commit into
NousResearch:mainfrom
0xyg3n:0xyg3n/discord-roles-dm-bypass-fix

Conversation

@0xyg3n

@0xyg3n 0xyg3n commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Security fix: DISCORD_ALLOWED_ROLES is 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)

  • Network (N): exploitable over Discord
  • Low attack complexity (L): just hold the role in any shared guild
  • Privileges required: Low (L): attacker needs basic Discord access to the public guild
  • User interaction: None (N)
  • Scope changed (C): privilege obtained in one guild applies to a different guild/DM
  • Confidentiality/Integrity: High: full bot access (chat, memory, tools, delegations)

Exploit scenario:

  1. Operator runs Hermes with DISCORD_ALLOWED_ROLES=111222333 to authorize moderators of a private trusted server B.
  2. The bot is also a member of a large public server A (e.g. a community / support server).
  3. An attacker in server A obtains role ID 111222333 (easily done if the role is anything non-trivially gated, or through a role ID collision they discover).
  4. The attacker DMs the bot. The allowlist iterates self._client.guilds, finds the role in server A, and authorizes the DM.
  5. The attacker now has authorized access to the bot and can exercise any capability it exposes (tool calls, memory, file access, LLM calls billed to the operator, cross-plugin side effects).

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):

# Fallback: scan mutual guilds for member's roles
if self._client is not None:
    ...
    for guild in self._client.guilds:   # <-- cross-guild scan
        m = guild.get_member(uid_int)
        if m is None:
            continue
        m_roles = getattr(m, "roles", None) or []
        if any(getattr(r, "id", None) in allowed_roles for r in m_roles):
            return True

Two design flaws:

  1. No origin-guild argument. _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.
  2. DM path silently reuses the mutual-guild scan. DMs have message.guild is None, but the fallback iterates all of self._client.guilds, which is exactly the cross-guild leak.

Fix

  1. Extend the signature to _is_allowed_user(user_id, author=None, *, guild=None, is_dm=False).
  2. Guild messages: role check is restricted to guild only. Author-object .roles are only trusted if author.guild.id == guild.id. Fallback resolves the Member in guild.get_member(...) — never in other mutual guilds.
  3. DMs: role-based auth is disabled by default. Operators who deliberately want role-based DM access can opt in via DISCORD_DM_ROLE_AUTH_GUILD=<guild_id>, which restricts the role lookup to a single explicitly-trusted guild.
  4. Call sites (on_message, voice listen loop) updated to pass guild + is_dm.

Backwards compatibility

  • DISCORD_ALLOWED_USERS unchanged — still authorizes in both DMs and guild channels.
  • Empty allowlists still allow everyone (unchanged).
  • Role-based auth on guild messages where the operator has the role in that same guild continues to work (the intended use case in Support DISCORD_ALLOWED_ROLES for role-based access control #7871).
  • Role-based auth on DMs now requires the new DISCORD_DM_ROLE_AUTH_GUILD opt-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 reject
  • test_dm_role_auth_requires_explicit_guild_optin — opt-in works
  • test_dm_role_auth_optin_rejects_when_not_member — opt-in scope is correct
  • test_guild_message_role_check_scoped_to_originating_guild — cross-guild guild-message bypass rejected
  • test_guild_message_role_check_allows_when_role_in_same_guild — positive path
  • test_guild_message_rejects_author_roles_from_different_guild — cached Member from wrong guild not trusted
  • test_user_id_allowlist_works_in_dm — backwards compat
  • test_user_id_allowlist_works_in_guild — backwards compat
  • test_empty_allowlists_allow_everyone — backwards compat

All 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

Deployment guidance

Operators currently using DISCORD_ALLOWED_ROLES:

  • If you only grant access in guild channels → no config change needed. The fix tightens semantics but preserves your use case.
  • If you rely on role-based auth for DMs → add DISCORD_DM_ROLE_AUTH_GUILD=<your_trusted_guild_id> to restore DM authorization against that one guild.
  • If you want the old cross-guild behavior (not recommended) → revert to DISCORD_ALLOWED_USERS with explicit user IDs.

cc @teknium1 — this fixes a security issue in the implementation you merged.

…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)
@0xyg3n

0xyg3n commented Apr 18, 2026

Copy link
Copy Markdown
Contributor Author

CI note — the 4 Tests failures on this PR are pre-existing on main, unrelated to this change.

Failing test Root cause
tests/hermes_cli/test_web_server.py::test_no_single_field_categories code_execution schema category has 1 field — assertion expects merged categories. Already fixed in #12139.
tests/run_agent/test_concurrent_interrupt.py::test_concurrent_interrupt_cancels_pending AttributeError: '_Stub' object has no attribute '_apply_pending_steer_to_tool_results' — test stub out of date after the /steer feature in 2edebed (#12116).
tests/run_agent/test_concurrent_interrupt.py::test_running_concurrent_worker_sees_is_interrupted Same /steer stub gap.
tests/tools/test_browser_camofox_state.py::test_config_version_matches_current_schema assert 19 == 18 — camofox _config_version bump mismatch. Also addressed in #12139.

This PR only touches gateway/platforms/discord.py and adds tests/gateway/test_discord_roles_dm_scope.py. The new test file and every existing discord-auth suite pass cleanly: 47/47 across test_discord_roles_dm_scope.py (9 new), test_discord_bot_auth_bypass.py, test_unauthorized_dm_behavior.py, test_discord_allowed_mentions.py.

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P0 Critical — data loss, security, crash loop comp/gateway Gateway runner, session dispatch, delivery platform/discord Discord bot adapter area/auth Authentication, OAuth, credential pools labels Apr 23, 2026
@teknium1

teknium1 commented May 7, 2026

Copy link
Copy Markdown
Contributor

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 _evaluate_slash_authorization so the slash surface can't bypass the same way, and a policy adjustment routing the DM opt-in through config.yaml (discord.dm_role_auth_guild) instead of the DISCORD_DM_ROLE_AUTH_GUILD env var — per repo convention .env is for secrets, and guild IDs are behavioral config.

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.

@teknium1 teknium1 closed this May 7, 2026
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 P0 Critical — data loss, security, crash loop platform/discord Discord bot adapter type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants