Skip to content

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

@0xyg3n

Description

@0xyg3n

Security issue

Severity: CVSS 8.1 (High) — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N

The DISCORD_ALLOWED_ROLES allowlist introduced in #11608 (authored by @0xyg3n in #9873) is not guild-scoped. Role membership in any mutual guild authorizes the user globally, including in DMs where no originating guild context exists.

Exploit path

  1. Operator runs Hermes with DISCORD_ALLOWED_ROLES=<role_id> intending to authorize moderators of a private trusted server B.
  2. The bot is also in a large public server A (community server, support server, etc.).
  3. An attacker obtains <role_id> in server A (role-ID collisions across servers are easy to engineer; many public servers hand out colorful non-privileged roles on reaction or join).
  4. Attacker DMs the bot. The allowlist iterates self._client.guilds, finds the role in server A, and authorizes the DM.
  5. Attacker now has authenticated access to the bot: tool calls, memory reads, LLM calls billed to the operator, file access, any cross-service side effect the bot exposes.

The same flaw permits authorized chat in guild A's channels when the role was configured for guild B, by iterating mutual guilds instead of checking message.guild.

Why this is critical

  • No operator is safe. Any Hermes deployment with DISCORD_ALLOWED_ROLES configured is exposed if the bot joins any public server.
  • Scope-changed (S:C): privilege obtained in one guild applies to a fundamentally different guild/DM — classic authorization boundary break.
  • No mitigation short of disabling DISCORD_ALLOWED_ROLES until fixed. Operators who don't know about the flaw have no signal.

Code location

gateway/platforms/discord.py, function _is_allowed_user:

# 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 flaws:

  1. Signature takes no guild / message argument, so callers can't provide origin context.
  2. DM path (guild=None) silently falls back to the cross-guild scan.

Fix

PR #12135 scopes role checks to the originating guild and disables role-based DM auth by default, with an explicit opt-in (DISCORD_DM_ROLE_AUTH_GUILD=<guild_id>) for operators who want it for a single trusted guild.

  • 9 regression tests covering the bypass, the opt-in, the cross-guild guild-message bypass, and backwards-compat user-ID paths.
  • 47/47 discord-auth tests pass. Zero regressions.

Recommendation

  1. Merge fix(discord): scope DISCORD_ALLOWED_ROLES to originating guild — cross-guild DM bypass (CVSS 8.1) #12135 as a security fix.
  2. Consider a GHSA advisory once merged — anyone with DISCORD_ALLOWED_ROLES set in production is currently exposed.
  3. Release notes should flag the DISCORD_ALLOWED_ROLES behavior tightening and the new DISCORD_DM_ROLE_AUTH_GUILD opt-in.

References

cc @teknium1

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0Critical — data loss, security, crash looparea/authAuthentication, OAuth, credential poolscomp/gatewayGateway runner, session dispatch, deliveryplatform/discordDiscord bot adaptertype/securitySecurity vulnerability or hardening

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions