fix(security): fail closed when an own-policy gateway adapter has no allowlist
Own-policy adapters (WhatsApp, WeCom, Weixin, QQBot, Yuanbao) default dm_policy/group_policy to "open", which forwards every sender. The gateway's adapter-trust shortcut in _is_user_authorized blanket-trusted those platforms when no env allowlist was set, so an operator who enabled one with only credentials authorized the entire external network -- the fail-open SECURITY.md section 2.6 forbids ("an allowlist is required for every enabled network-exposed adapter").
Trust the adapter only when its effective policy for the chat type is an actual "allowlist" restriction (the case #34515 was protecting). "open"/"pairing"/anything else falls through to default-deny, where {PLATFORM}_ALLOW_ALL_USERS / GATEWAY_ALLOW_ALL_USERS and the pairing flow remain the explicit opt-ins.
This commit is contained in:
+69
-27
@@ -37,10 +37,12 @@ class GatewayAuthorizationMixin:
|
||||
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
|
||||
such as WeCom, Weixin, Yuanbao, QQBot, and WhatsApp evaluate their
|
||||
documented ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
|
||||
message is dispatched to the gateway, so a message that reaches
|
||||
``_is_user_authorized`` has already been authorized by the adapter.
|
||||
Defaults to ``False`` when the adapter is unknown or doesn't expose
|
||||
the flag.
|
||||
message is dispatched to the gateway. The flag alone is NOT "already
|
||||
authorized": these adapters default to ``open``, which forwards every
|
||||
sender, so ``_is_user_authorized`` only trusts the adapter when its
|
||||
effective policy for the chat type is an actual ``allowlist`` restriction
|
||||
(see that method). Defaults to ``False`` when the adapter is unknown or
|
||||
doesn't expose the flag.
|
||||
"""
|
||||
if not platform:
|
||||
return False
|
||||
@@ -65,10 +67,11 @@ class GatewayAuthorizationMixin:
|
||||
env var is not always bridged back into ``config.extra``) — and falls
|
||||
back to ``config.extra`` for bare runners built without a live adapter.
|
||||
|
||||
Used by ``_is_user_authorized`` to carve ``dm_policy: pairing`` out of
|
||||
the adapter-trust shortcut: in pairing mode the adapter forwards the DM
|
||||
so the gateway can run its pairing handshake, so "reached the gateway"
|
||||
must not be read as "authorized".
|
||||
Used by ``_is_user_authorized`` to decide whether an own-policy adapter
|
||||
actually restricted DM senders to a configured allowlist (trustworthy)
|
||||
or merely forwarded everyone under ``dm_policy: open`` / for a pairing
|
||||
handshake (not authorization). "Reached the gateway" only carries an
|
||||
authorization signal in the ``allowlist`` case.
|
||||
"""
|
||||
if not platform:
|
||||
return ""
|
||||
@@ -87,6 +90,37 @@ class GatewayAuthorizationMixin:
|
||||
policy = extra.get("dm_policy")
|
||||
return str(policy or "").strip().lower()
|
||||
|
||||
def _adapter_group_policy(self, platform: Optional[Platform]) -> str:
|
||||
"""Best-effort read of an own-policy adapter's effective group policy.
|
||||
|
||||
Mirror of ``_adapter_dm_policy`` for group / forum / channel traffic:
|
||||
returns the lowercased ``group_policy`` (``"open"`` / ``"allowlist"`` /
|
||||
``"disabled"``) for *platform*, or ``""`` when unknown. Prefers the live
|
||||
adapter's resolved ``_group_policy`` and falls back to ``config.extra``
|
||||
for bare runners built without a live adapter.
|
||||
|
||||
Used by ``_is_user_authorized`` to decide whether an own-policy adapter
|
||||
restricted group senders to a configured allowlist (trustworthy) or
|
||||
forwarded the whole channel under ``group_policy: open`` (not
|
||||
authorization).
|
||||
"""
|
||||
if not platform:
|
||||
return ""
|
||||
adapters = getattr(self, "adapters", None) or {}
|
||||
adapter = adapters.get(platform)
|
||||
policy = getattr(adapter, "_group_policy", None) if adapter is not None else None
|
||||
if policy is None:
|
||||
config = getattr(self, "config", None)
|
||||
platform_cfg = (
|
||||
config.platforms.get(platform)
|
||||
if config is not None and hasattr(config, "platforms")
|
||||
else None
|
||||
)
|
||||
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
|
||||
if isinstance(extra, dict):
|
||||
policy = extra.get("group_policy")
|
||||
return str(policy or "").strip().lower()
|
||||
|
||||
def _is_user_authorized(self, source: SessionSource) -> bool:
|
||||
"""
|
||||
Check if a user is authorized to use the bot.
|
||||
@@ -237,27 +271,35 @@ class GatewayAuthorizationMixin:
|
||||
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
|
||||
|
||||
if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:
|
||||
# No env allowlists configured. Adapters that own their own
|
||||
# No env allowlist configured. Adapters that own their own
|
||||
# config-driven access policy (dm_policy / group_policy /
|
||||
# allow_from / group_allow_from) already gated this message at
|
||||
# intake — it would not have reached the gateway otherwise — so
|
||||
# honor that decision instead of falling through to the
|
||||
# env-only default-deny below, which would silently break
|
||||
# `dm_policy: open` and config-only allowlists. (#34515)
|
||||
# allow_from / group_allow_from) gate access at intake, so for those
|
||||
# platforms we can honor the adapter's decision instead of the
|
||||
# env-only default-deny below -- but ONLY when that decision was an
|
||||
# actual allowlist restriction.
|
||||
#
|
||||
# The adapters default dm_policy / group_policy to "open", which
|
||||
# forwards EVERY sender. Reading "reached the gateway" as
|
||||
# authorization in that case would admit the whole external network
|
||||
# with no operator-configured allowlist -- the fail-open SECURITY.md
|
||||
# §2.6 forbids ("an allowlist is required for every enabled
|
||||
# network-exposed adapter ... code paths that fail open when no
|
||||
# allowlist is configured are code bugs"). "disabled" never
|
||||
# forwards, and "pairing" forwards unpaired DMs only so the gateway
|
||||
# can run its pairing handshake (the pairing-store check above
|
||||
# already denied this sender). So trust the adapter only when its
|
||||
# effective policy for THIS chat type is "allowlist"; for "open" /
|
||||
# "pairing" / anything else, fall through to default-deny, where
|
||||
# GATEWAY_ALLOW_ALL_USERS, the per-platform {PLATFORM}_ALLOW_ALL_USERS
|
||||
# flag (checked above), and the pairing flow remain the explicit
|
||||
# opt-ins to broader access. (#34515 follow-up: trusting "open" was a
|
||||
# fail-open.)
|
||||
if self._adapter_enforces_own_access_policy(source.platform):
|
||||
# Exception: `dm_policy: pairing` does NOT authorize at intake.
|
||||
# The adapter forwards the DM precisely so the gateway can run
|
||||
# its pairing handshake (issue a code, consult the pairing
|
||||
# store). The pairing-store approval check above already ran and
|
||||
# returned False for this sender, so blanket-trusting the
|
||||
# adapter here would silently turn pairing mode into open
|
||||
# access. Fall through to default-deny so the unpaired sender is
|
||||
# offered a pairing code instead. (Pairing is DM-only; group
|
||||
# traffic keeps the adapter-trust path.)
|
||||
if not (
|
||||
source.chat_type == "dm"
|
||||
and self._adapter_dm_policy(source.platform) == "pairing"
|
||||
):
|
||||
if source.chat_type in {"group", "forum", "channel"}:
|
||||
effective_policy = self._adapter_group_policy(source.platform)
|
||||
else:
|
||||
effective_policy = self._adapter_dm_policy(source.platform)
|
||||
if effective_policy == "allowlist":
|
||||
return True
|
||||
# No allowlists configured -- check global allow-all flag
|
||||
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
|
||||
|
||||
@@ -1916,16 +1916,21 @@ class BasePlatformAdapter(ABC):
|
||||
enforce it at intake: a message is dropped inside the adapter and never
|
||||
reaches the gateway unless it already passed that policy.
|
||||
|
||||
The gateway's env-based allowlist check runs *after* the adapter, so for
|
||||
these platforms a message arriving at ``_is_user_authorized`` has, by
|
||||
definition, already been authorized by the adapter. Without this flag the
|
||||
gateway would then deny it again (no env allowlist → default deny),
|
||||
silently breaking ``dm_policy: open`` and config-only allowlists.
|
||||
The gateway's env-based allowlist check runs *after* the adapter. When
|
||||
no env allowlist is configured, the gateway consults this flag so it can
|
||||
honor a config-only ``dm_policy: allowlist`` / ``allow_from`` (which the
|
||||
adapter already enforced) instead of double-denying it. Crucially, the
|
||||
flag alone is NOT "already authorized": these adapters default
|
||||
``dm_policy`` / ``group_policy`` to ``"open"``, which forwards every
|
||||
sender, so the gateway trusts the adapter only when its effective policy
|
||||
for the chat type is an actual ``"allowlist"`` restriction — never for
|
||||
``"open"`` (that would be the network-exposed fail-open SECURITY.md §2.6
|
||||
forbids). Open access still requires an explicit
|
||||
``{PLATFORM}_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOW_ALL_USERS`` opt-in.
|
||||
|
||||
Adapters that own their access policy override this to return ``True``.
|
||||
The gateway treats that as "already authorized at intake" and skips the
|
||||
env-allowlist default-deny. Adapters that delegate access control to the
|
||||
gateway leave it ``False`` (the default).
|
||||
Adapters that delegate access control to the gateway leave it ``False``
|
||||
(the default).
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user