fix(discord): fail closed for component button auth when no allowlist set

Salvage of the Discord half of PR #30964 by @LaPhilosophie. Discord
component button callbacks (ExecApprovalView, SlashConfirmView,
UpdatePromptView, ModelPickerView) bypass the normal message dispatch
authorization path. _component_check_auth previously returned True when
both the user and role allowlists were empty, so any guild member who
could see an approval prompt could click Approve on a dangerous command.

Fail closed instead: require DISCORD_ALLOWED_USERS / DISCORD_ALLOWED_ROLES
/ GATEWAY_ALLOWED_USERS membership, or an explicit DISCORD_ALLOW_ALL_USERS
/ GATEWAY_ALLOW_ALL_USERS opt-in for deliberately-open deployments.

Mirrors the Telegram (#24457) and Matrix fail-closed precedent.
The Slack half of #30964 is superseded by PR #33844's helper.

Reported via GHSA-mc26-p6fw-7pp6 (@whyiug).

Co-authored-by: LaPhilosophie <804436395@qq.com>
This commit is contained in:
LaPhilosophie
2026-06-07 06:21:37 -07:00
committed by Teknium
parent 3fa15b33dd
commit f6f363662e
2 changed files with 112 additions and 35 deletions
+19 -18
View File
@@ -5188,34 +5188,35 @@ def _component_check_auth(
) -> bool:
"""Shared user-or-role OR semantics for component view button clicks.
Mirrors ``DiscordAdapter._is_allowed_user`` / the slash and on_message
gates so every Discord interaction surface honors the same trust
boundary. Component views (ExecApprovalView, SlashConfirmView,
UpdatePromptView, ModelPickerView) used to receive only
``allowed_user_ids``: in role-only deployments
(DISCORD_ALLOWED_ROLES set, DISCORD_ALLOWED_USERS empty) the user
set was empty and the legacy "no allowlist = allow everyone" branch
let any guild member click the buttons -- approving exec commands,
cancelling slash confirmations, switching the model.
Mirrors the gateway's external-surface authorization model: component
button clicks must be explicitly authorized by a Discord user/role
allowlist, a global user allowlist, or an explicit allow-all flag.
Behavior:
- both allowlists empty -> allow (preserves existing no-allowlist
deployments, no regression)
- user is in user allowlist -> allow
- DISCORD_ALLOW_ALL_USERS or GATEWAY_ALLOW_ALL_USERS -> allow
- user is in DISCORD_ALLOWED_USERS or GATEWAY_ALLOWED_USERS -> allow
- role allowlist set + user has a role in it -> allow
- role allowlist set + interaction.user has no resolvable
``roles`` attribute (e.g. DM context with a role policy active)
-> reject (fail closed)
- otherwise -> reject
"""
user_set = allowed_user_ids or set()
role_set = allowed_role_ids or set()
has_users = bool(user_set)
has_roles = bool(role_set)
if not has_users and not has_roles:
if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
return True
if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
return True
user_set = {str(uid).strip() for uid in (allowed_user_ids or set()) if str(uid).strip()}
global_allowed = {
uid.strip()
for uid in os.getenv("GATEWAY_ALLOWED_USERS", "").split(",")
if uid.strip()
}
user_set.update(global_allowed)
role_set = set(allowed_role_ids or set())
has_users = bool(user_set)
has_roles = bool(role_set)
user = getattr(interaction, "user", None)
if user is None:
return False
@@ -5225,7 +5226,7 @@ def _component_check_auth(
uid = str(user.id)
except AttributeError:
uid = ""
if uid and uid in user_set:
if "*" in user_set or (uid and uid in user_set):
return True
if has_roles: