Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gateway/platforms/qqbot/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1420,6 +1420,7 @@ async def _handle_guild_message(
user_id=str(author.get("id", "")),
user_name=nick or None,
chat_type="group",
guild_id=guild_id or None,
),
text=text,
message_type=self._detect_message_type(image_urls, image_media_types),
Expand Down
130 changes: 120 additions & 10 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -6928,8 +6928,9 @@ def _adapter_enforces_own_access_policy(self, platform: Optional[Platform]) -> b
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.
message is dispatched to the gateway. The gateway still requires a
concrete configured allowlist before treating that adapter gate as
authorization for external callers.
Defaults to ``False`` when the adapter is unknown or doesn't expose
the flag.
"""
Expand All @@ -6946,6 +6947,118 @@ def _adapter_enforces_own_access_policy(self, platform: Optional[Platform]) -> b
return False
return bool(getattr(adapter, "enforces_own_access_policy", False))

def _get_adapter(self, platform: Optional[Platform]) -> Any:
adapters = getattr(self, "adapters", None)
if not platform or not adapters:
return None
return adapters.get(platform)

@staticmethod
def _group_extra(extra: dict[str, Any], chat_id: Optional[str]) -> dict[str, Any]:
groups = extra.get("groups")
if not isinstance(groups, dict) or not chat_id:
return {}
group_cfg = groups.get(chat_id)
if isinstance(group_cfg, dict):
return group_cfg
lowered = str(chat_id).lower()
for key, value in groups.items():
if isinstance(key, str) and key.lower() == lowered and isinstance(value, dict):
return value
return {}

@staticmethod
def _coerce_access_entries(raw: Any) -> set[str]:
if raw is None:
return set()
if isinstance(raw, str):
return {part.strip() for part in raw.split(",") if part.strip()}
if isinstance(raw, (list, tuple, set, frozenset)):
return {str(part).strip() for part in raw if str(part).strip()}
return {str(raw).strip()} if str(raw).strip() else set()

@staticmethod
def _access_entry_matches(entries: set[str], target: Optional[str], platform: Optional[Platform]) -> bool:
if not target:
return False
if "*" in entries:
return True
if platform == Platform.YUANBAO:
normalized_target = str(target).strip()
if normalized_target.startswith("group:"):
normalized_target = normalized_target.removeprefix("group:")
return normalized_target in entries
if platform in {Platform.WECOM, Platform.QQBOT}:
normalized_target = str(target).strip().lower()
for entry in entries:
normalized_entry = re.sub(
r"^(?:wecom:)?(?:user|group):",
"",
str(entry).strip(),
flags=re.IGNORECASE,
).lower()
if normalized_entry == normalized_target:
return True
return False
return str(target).strip() in entries

def _source_matches_configured_access_policy(self, source: SessionSource) -> bool:
"""Return True only for adapter-owned policies with concrete allowlists."""
if not self._adapter_enforces_own_access_policy(source.platform):
return False

adapter = self._get_adapter(source.platform)
access_policy = getattr(adapter, "_access_policy", None)
platform_cfg = self.config.platforms.get(source.platform) if self.config else None
extra = getattr(platform_cfg, "extra", None) or {}

if source.chat_type in {"group", "forum"}:
group_policy = str(
getattr(access_policy, "group_policy", None)
or getattr(adapter, "_group_policy", None)
or extra.get("group_policy")
or ""
).strip().lower()
if group_policy != "allowlist":
sender_entries = self._coerce_access_entries(
self._group_extra(extra, source.chat_id).get("allow_from")
or self._group_extra(extra, source.chat_id).get("allowFrom")
)
return self._access_entry_matches(sender_entries, source.user_id, source.platform)

group_entries = self._coerce_access_entries(
getattr(access_policy, "group_allow_from", None)
or getattr(adapter, "_group_allow_from", None)
or extra.get("group_allow_from")
or extra.get("groupAllowFrom")
)
if not group_entries:
return False
group_target = source.chat_id
if source.platform == Platform.QQBOT and source.guild_id:
group_target = source.guild_id
return self._access_entry_matches(group_entries, group_target, source.platform)

dm_policy = str(
getattr(access_policy, "dm_policy", None)
or getattr(adapter, "_dm_policy", None)
or extra.get("dm_policy")
or ""
).strip().lower()
if dm_policy != "allowlist":
return False

dm_entries = self._coerce_access_entries(
getattr(access_policy, "dm_allow_from", None)
or getattr(adapter, "_allow_from", None)
or extra.get("allow_from")
or extra.get("allowFrom")
or extra.get("dm_allow_from")
)
if not dm_entries:
return False
return self._access_entry_matches(dm_entries, source.user_id, source.platform)

def _is_user_authorized(self, source: SessionSource) -> bool:
"""
Check if a user is authorized to use the bot.
Expand Down Expand Up @@ -7085,14 +7198,11 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
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
# 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)
if self._adapter_enforces_own_access_policy(source.platform):
# No env allowlists configured. An adapter-owned access policy is
# enough only when it is backed by a concrete allowlist. Default
# "open" adapter policies must not turn an empty gateway allowlist
# into network-wide authorization.
if self._source_matches_configured_access_policy(source):
return True
# No allowlists configured -- check global allow-all flag
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
Expand Down
163 changes: 144 additions & 19 deletions tests/gateway/test_config_driven_access_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,14 @@
already passed that policy.

The gateway's env-based allowlist check (``_is_user_authorized``) runs *after*
the adapter. Before the fix it fell through to an env-only default-deny when no
``PLATFORM_ALLOWED_USERS`` env var was set, silently rejecting ``dm_policy:
open`` and config-only allowlists even though the adapter had already
authorized the sender.

The fix is a single drift-proof contract: adapters that own their access policy
declare ``enforces_own_access_policy`` (a ``BasePlatformAdapter`` property,
default ``False``). The gateway trusts that flag and skips the env-only
default-deny for those platforms, rather than re-implementing each adapter's
policy logic a second time.
the adapter. A prior compatibility fix trusted any adapter that declared
``enforces_own_access_policy`` when gateway env allowlists were empty. That made
``dm_policy: open`` / ``group_policy: open`` equivalent to network-wide gateway
authorization.

The security contract is narrower: adapters may declare that they own an intake
policy, but the gateway only treats that intake decision as authorization when
the current source is backed by a concrete configured allowlist.
"""

from types import SimpleNamespace
Expand Down Expand Up @@ -123,38 +121,165 @@ def test_own_policy_adapters_declare_the_flag(module_path, class_name):


# ---------------------------------------------------------------------------
# Layer 2: gateway trusts the adapter-enforced flag
# Layer 2: gateway trusts only concrete adapter allowlists
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
def test_own_policy_platform_authorized_without_env_allowlist(monkeypatch, platform):
"""A message reaching the gateway from an own-policy adapter is trusted.

With no env allowlist set, the gateway must NOT default-deny — the adapter
already authorized the sender at intake (e.g. ``dm_policy: open``).
"""
def test_own_policy_open_dm_default_denies_without_allowlist(monkeypatch, platform):
"""Open adapter policy is not enough to authorize network callers."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
)
runner, _adapter = _make_runner(platform, config, enforces=True)

assert runner._is_user_authorized(_source(platform)) is False


@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
def test_own_policy_allowlisted_dm_is_authorized_without_env_allowlist(monkeypatch, platform):
"""Config-only adapter allowlists remain valid authorization evidence."""
_clear_auth_env(monkeypatch)
allowlist_key = "dm_allow_from" if platform == Platform.YUANBAO else "allow_from"
config = GatewayConfig(
platforms={
platform: PlatformConfig(
enabled=True,
extra={"dm_policy": "allowlist", allowlist_key: ["some-user"]},
)
}
)
runner, _adapter = _make_runner(platform, config, enforces=True)

assert runner._is_user_authorized(_source(platform)) is True


@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
def test_own_policy_platform_authorized_for_group_chat(monkeypatch, platform):
"""Group traffic from an own-policy adapter is trusted the same way."""
def test_own_policy_allowlisted_dm_rejects_non_matching_sender(monkeypatch, platform):
"""The gateway re-checks the concrete adapter allowlist before trusting it."""
_clear_auth_env(monkeypatch)
allowlist_key = "dm_allow_from" if platform == Platform.YUANBAO else "allow_from"
config = GatewayConfig(
platforms={
platform: PlatformConfig(
enabled=True,
extra={"dm_policy": "allowlist", allowlist_key: ["allowed-user"]},
)
}
)
runner, _adapter = _make_runner(platform, config, enforces=True)

assert runner._is_user_authorized(_source(platform)) is False


@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
def test_own_policy_open_group_default_denies_without_allowlist(monkeypatch, platform):
"""Open group policy is not enough to authorize network callers."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={platform: PlatformConfig(enabled=True, extra={"group_policy": "open"})}
)
runner, _adapter = _make_runner(platform, config, enforces=True)

assert runner._is_user_authorized(_source(platform, chat_type="group")) is False


@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
def test_own_policy_allowlisted_group_is_authorized_without_env_allowlist(monkeypatch, platform):
"""Config-only group allowlists remain valid authorization evidence."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={
platform: PlatformConfig(
enabled=True,
extra={"group_policy": "allowlist", "group_allow_from": ["some-chat"]},
)
}
)
runner, _adapter = _make_runner(platform, config, enforces=True)

assert runner._is_user_authorized(_source(platform, chat_type="group")) is True


def test_yuanbao_env_parsed_dm_allowlist_is_authorized(monkeypatch):
"""Adapter-parsed env allowlists are trusted without duplicating env reads."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(platforms={Platform.YUANBAO: PlatformConfig(enabled=True)})
runner, adapter = _make_runner(Platform.YUANBAO, config, enforces=True)
adapter._access_policy = SimpleNamespace(
dm_policy="allowlist",
dm_allow_from=["some-user"],
group_policy="open",
group_allow_from=[],
)

assert runner._is_user_authorized(_source(Platform.YUANBAO)) is True


def test_weixin_env_parsed_group_allowlist_is_authorized(monkeypatch):
"""Group allowlists parsed by the adapter remain valid gateway evidence."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(platforms={Platform.WEIXIN: PlatformConfig(enabled=True)})
runner, adapter = _make_runner(Platform.WEIXIN, config, enforces=True)
adapter._group_policy = "allowlist"
adapter._group_allow_from = ["some-chat"]

assert runner._is_user_authorized(_source(Platform.WEIXIN, chat_type="group")) is True


def test_yuanbao_group_allowlist_matches_raw_group_code(monkeypatch):
"""Yuanbao stores raw group codes while SessionSource chat_id is prefixed."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(platforms={Platform.YUANBAO: PlatformConfig(enabled=True)})
runner, adapter = _make_runner(Platform.YUANBAO, config, enforces=True)
adapter._access_policy = SimpleNamespace(
dm_policy="open",
dm_allow_from=[],
group_policy="allowlist",
group_allow_from=["some-chat"],
)

source = _source(Platform.YUANBAO, chat_type="group")
source.chat_id = "group:some-chat"
assert runner._is_user_authorized(source) is True


def test_wecom_config_allowlist_uses_adapter_normalization(monkeypatch):
"""WeCom prefixes and case are normalized like the adapter intake check."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={
Platform.WECOM: PlatformConfig(
enabled=True,
extra={"dm_policy": "allowlist", "allow_from": ["wecom:USER:Some-User"]},
)
}
)
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)

assert runner._is_user_authorized(_source(Platform.WECOM, chat_type="dm")) is True


def test_qqbot_guild_group_allowlist_matches_guild_id(monkeypatch):
"""QQBot guild messages are allowlisted by guild id, not channel id."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={
Platform.QQBOT: PlatformConfig(
enabled=True,
extra={"group_policy": "allowlist", "group_allow_from": ["guild-1"]},
)
}
)
runner, _adapter = _make_runner(Platform.QQBOT, config, enforces=True)
source = _source(Platform.QQBOT, chat_type="group")
source.chat_id = "channel-1"
source.guild_id = "guild-1"

assert runner._is_user_authorized(source) is True


def test_non_owning_platform_still_default_denies(monkeypatch):
"""Adapters that don't own their policy keep the env-only default-deny."""
_clear_auth_env(monkeypatch)
Expand Down