mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
fix(wechat): deny all users when allowed_users has no valid ID (#6376)
* fix(wechat): deny all users when allowed_users has no valid ID A single iLink user ID was split into characters, and a list with no usable ID collapsed to an empty set that the inbound gate read as allow-all. * fix(wechat): fail closed on numeric and mapping allowed_users Integer-valued floats now match the sender's integer text. Fractions, non-finite numbers, and mappings no longer admit anyone, and a denied sender is dropped before inbound media is downloaded. * docs(channels): keep WeChat guidance within size budget * fix(wechat): warn on scalar allowlist separators * refactor(channels): share fail-closed allowlist parsing --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
eeb2c4dbcc
commit
a97772d016
@ -868,7 +868,7 @@ channels:
|
||||
bot_token: $WECHAT_BOT_TOKEN
|
||||
ilink_bot_id: $WECHAT_ILINK_BOT_ID
|
||||
qrcode_login_enabled: true # optional: allow first-time QR bootstrap when bot_token is absent
|
||||
allowed_users: [] # empty = allow all
|
||||
allowed_users: [] # iLink user IDs; empty allows all; one ID is one entry
|
||||
polling_timeout: 35 # timing values must be positive finite seconds
|
||||
polling_retry_delay: 5
|
||||
qrcode_poll_interval: 2
|
||||
@ -969,6 +969,7 @@ DINGTALK_CLIENT_SECRET=your_client_secret
|
||||
4. After the QR flow succeeds, DeerFlow persists the acquired token under `state_dir` for later restarts.
|
||||
5. For Docker Compose deployments, keep `state_dir` on a persistent volume so the `get_updates_buf` cursor and saved auth state survive restarts.
|
||||
6. Outbound images/files enforce `max_outbound_image_bytes` / `max_outbound_file_bytes` (20 MiB / 50 MiB defaults) while reading, including files that grow after resolution. Oversize reads are rejected before encryption/upload instead of sending a truncated prefix. Non-positive limits disable the corresponding cap.
|
||||
7. `allowed_users` takes iLink user IDs. Unset, `null`, `[]`, or a blank string allows everyone. A single ID is one entry, not a sequence of characters, and an unquoted integer-valued number is stored as that integer's text. A scalar string containing commas or interior whitespace logs a warning but remains one literal ID; use a YAML list for multiple IDs. Invalid entries are ignored with a warning; any other configured value that yields no valid ID denies every user and logs an error. `/connect` is still accepted before that check, and a denied sender is dropped before inbound media is downloaded.
|
||||
|
||||
**WeCom Setup**
|
||||
|
||||
|
||||
@ -60,8 +60,9 @@ The cached value is reused for both the blocking (`runs.wait`) and streaming (`_
|
||||
**User-owned channel connections** (`config.yaml` -> `channel_connections`):
|
||||
- Disabled by default. It is a user-binding layer on top of the existing `channels.*` runtime config, not a replacement for provider bot credentials.
|
||||
- No public IP, OAuth callback URL, or provider webhook route is required by the current implementation.
|
||||
- Telegram uses a deep-link `/start <code>` flow over the existing long-polling worker. Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom use `/connect <code>` over their existing outbound channel workers.
|
||||
- WeChat timing settings (`polling_timeout`, `polling_retry_delay`, `qrcode_poll_interval`, `qrcode_poll_timeout`) accept only positive finite seconds; invalid values fall back to their defaults so polling cannot enter a hot loop or sleep forever.
|
||||
- Telegram binds via deep-link `/start <code>` over long polling; Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom use `/connect <code>` through their outbound workers.
|
||||
- WeChat `polling_timeout`, `polling_retry_delay`, `qrcode_poll_interval`, and `qrcode_poll_timeout` require positive finite seconds; invalid values use defaults to prevent hot loops or endless sleeps.
|
||||
- `allowed_users.py` shares WeChat/Telegram fail-closed parsing with channel-specific ID coercion: unset/null/empty collections/blank string allow all; invalid entries warn, all-invalid values deny all and log an error. WeChat stores integer-valued numbers as integer text, warns on scalar commas/interior whitespace without splitting, and rejects denied senders before media downloads.
|
||||
- WeCom serializes `start()` and `stop()` for each channel instance. The SDK `connect()` task covers connection setup only; after the handshake, the SDK owns a separate receive task. Shutdown cancels an in-progress connection attempt and awaits the SDK's actual asynchronous receive-task/socket cleanup before releasing lifecycle state or allowing a restart. Cancellation of `stop()` still propagates, but only after owned cleanup finishes and lifecycle references are cleared; real connection failures remain reported by `_on_ws_task_done`.
|
||||
- WeCom outbound content is capped at the protocol's 20480 UTF-8 bytes: stream replies clip on a character boundary with a truncation marker (one stream carries the whole reply and cannot split mid-way), while proactive pushes split into at most 10 sequential markdown messages per push, with the remaining tail clipped and marker-terminated. Both paths measure bytes, not characters. A per-chat send lock serializes each split batch end to end, because manager workers run concurrently and two long pushes to the same chat would otherwise interleave chunks; locks are reference-counted and reclaimed once no sender holds or waits on them, so the registry does not grow for the life of the Gateway.
|
||||
- Frontend APIs: `GET /api/channels/providers`, `GET /api/channels/connections`, `POST /api/channels/{provider}/connect`, and `DELETE /api/channels/connections/{connection_id}`.
|
||||
@ -70,7 +71,7 @@ The cached value is reused for both the blocking (`runs.wait`) and streaming (`_
|
||||
- Provider-level `connection_status` reflects the user's newest connection row. With no binding it is `not_connected`, except in auth-disabled local mode where a configured running channel reports `connected` because all channel messages already route to the default user.
|
||||
- Slack replies use the configured operator bot token from `channels.slack` unless per-connection credentials are present; unreadable or corrupt stored credentials are treated as unavailable.
|
||||
- Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom workers resolve incoming platform identities to connection records before reaching `ChannelManager`.
|
||||
- **Connect-code ordering vs `allowed_users`**: inbound workers consume a valid `/connect <code>` (or Telegram `/start <code>`) **before** applying the `allowed_users` filter, so a newly allowlisted-but-unbound user can bootstrap their first bind via the browser flow. Consequence: `allowed_users` is **not** a bind-time defense — any sender who possesses a valid code can consume it (not only allowlisted users). The bind security model rests on the code's confidentiality: `secrets.token_urlsafe(16)`, 600 s TTL, one-time `consume_oauth_state`, and codes surfaced only in the initiating browser (never echoed to chat). `allowed_users` still gates ordinary (non-bind) messages.
|
||||
- **Connect-code ordering**: `/connect <code>` (Telegram `/start <code>`) precedes `allowed_users`, so even non-allowlisted senders can bind with a valid code. The allowlist gates ordinary messages. Codes rely on secrecy: `secrets.token_urlsafe(16)`, 600 s TTL, one-time `consume_oauth_state`, shown only in the initiating browser, never echoed to chat.
|
||||
- **Single-active-owner transfer semantics**: an external identity is keyed by `(provider, external_account_id, workspace_id)`. The latest successful bind wins — `upsert_connection` revokes other owners' active rows for the same identity (ownership transfer). This invariant is enforced at the DB layer by the partial unique index `uq_channel_connection_active_identity` (`WHERE status != 'revoked'`), so concurrent connects from different owners cannot both end `connected`; the losing writer retries against the now-visible state. `find_connection_by_external_identity` therefore resolves deterministically.
|
||||
- See `backend/docs/IM_CHANNEL_CONNECTIONS.md` for provider setup, operational notes, and the architecture diagrams (connect-code flow, single-active-owner transfer, sync vs streaming dispatch, owner-scoped file storage pipeline).
|
||||
|
||||
|
||||
41
backend/app/channels/allowed_users.py
Normal file
41
backend/app/channels/allowed_users.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Shared fail-closed parsing for channel user allowlists."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Hashable
|
||||
from typing import Any
|
||||
|
||||
|
||||
def parse_allowed_users[UserId: Hashable](
|
||||
allowed_users: Any,
|
||||
*,
|
||||
parse_user_id: Callable[[Any], UserId | None],
|
||||
logger: logging.Logger,
|
||||
channel_name: str,
|
||||
expected_id: str,
|
||||
valid_id_name: str = "user ID",
|
||||
) -> frozenset[UserId] | None:
|
||||
"""Return ``None`` for an unset/empty allowlist, or its valid user IDs.
|
||||
|
||||
Scalars are single entries; mappings must not authorize their keys. The
|
||||
channel's coercer validates each entry. Invalid entries warn and are dropped,
|
||||
but a configured value with no valid entry returns an empty set and logs an
|
||||
error, so callers deny everyone instead of silently removing the restriction.
|
||||
"""
|
||||
if allowed_users is None or (isinstance(allowed_users, str) and not allowed_users.strip()):
|
||||
return None
|
||||
entries = list(allowed_users) if isinstance(allowed_users, (list, tuple, set, frozenset)) else [allowed_users]
|
||||
if not entries:
|
||||
return None
|
||||
|
||||
user_ids: set[UserId] = set()
|
||||
for entry in entries:
|
||||
user_id = parse_user_id(entry)
|
||||
if user_id is None:
|
||||
logger.warning("[%s] Ignoring allowed_users entry %r: expected %s; list several IDs as a YAML list", channel_name, entry, expected_id)
|
||||
else:
|
||||
user_ids.add(user_id)
|
||||
if not user_ids:
|
||||
logger.error("[%s] allowed_users has no valid %s; denying every user until it is fixed", channel_name, valid_id_name)
|
||||
return frozenset(user_ids)
|
||||
@ -10,6 +10,7 @@ import time
|
||||
from collections.abc import Coroutine
|
||||
from typing import Any
|
||||
|
||||
from app.channels.allowed_users import parse_allowed_users
|
||||
from app.channels.base import Channel, ChannelStopTimeout
|
||||
from app.channels.connection_identity import attach_connection_identity
|
||||
from app.channels.message_bus import (
|
||||
@ -113,28 +114,15 @@ def _parse_telegram_user_id(entry: Any) -> int | None:
|
||||
|
||||
|
||||
def _parse_allowed_users(allowed_users: Any) -> frozenset[int] | None:
|
||||
"""Parse ``channels.telegram.allowed_users``; ``None`` means no allowlist.
|
||||
|
||||
A single ID is shorthand for a one-entry list. Entries that are not numeric
|
||||
user IDs (``@usernames``, floats, booleans) are dropped with a warning. If
|
||||
none remain, the result is an empty set that denies everyone: the operator
|
||||
asked for a restriction, so an unreadable one must not open the bot to all.
|
||||
"""
|
||||
if allowed_users is None or (isinstance(allowed_users, str) and not allowed_users.strip()):
|
||||
return None
|
||||
entries = list(allowed_users) if isinstance(allowed_users, list | tuple | set) else [allowed_users]
|
||||
if not entries:
|
||||
return None
|
||||
user_ids: set[int] = set()
|
||||
for entry in entries:
|
||||
user_id = _parse_telegram_user_id(entry)
|
||||
if user_id is None:
|
||||
logger.warning("[Telegram] Ignoring allowed_users entry %r: expected a positive numeric Telegram user ID (not an @username); list several IDs as a YAML list", entry)
|
||||
else:
|
||||
user_ids.add(user_id)
|
||||
if not user_ids:
|
||||
logger.error("[Telegram] allowed_users has no valid numeric user ID; denying every user until it is fixed")
|
||||
return frozenset(user_ids)
|
||||
"""Parse positive numeric Telegram IDs with shared fail-closed semantics."""
|
||||
return parse_allowed_users(
|
||||
allowed_users,
|
||||
parse_user_id=_parse_telegram_user_id,
|
||||
logger=logger,
|
||||
channel_name="Telegram",
|
||||
expected_id="a positive numeric Telegram user ID (not an @username)",
|
||||
valid_id_name="numeric user ID",
|
||||
)
|
||||
|
||||
|
||||
class TelegramChannel(Channel):
|
||||
|
||||
@ -23,6 +23,7 @@ import httpx
|
||||
from cryptography.hazmat.primitives import padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
from app.channels.allowed_users import parse_allowed_users
|
||||
from app.channels.base import Channel
|
||||
from app.channels.commands import is_known_channel_command
|
||||
from app.channels.connection_identity import attach_connection_identity
|
||||
@ -168,6 +169,41 @@ def _detect_image_extension_and_mime(content: bytes) -> tuple[str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_wechat_user_id(entry: Any) -> str | None:
|
||||
"""A valid iLink user ID from a string or numeric scalar, else ``None``.
|
||||
|
||||
A finite integer-valued float uses its integer text, so YAML ``12345.0``
|
||||
matches the sender ``12345``. Fractional and non-finite floats are not IDs.
|
||||
A numeric string is kept as written: ``"12345.0"`` does not become ``12345``.
|
||||
"""
|
||||
if isinstance(entry, bool):
|
||||
# bool is an int subclass: reject booleans before the numeric branch.
|
||||
return None
|
||||
if isinstance(entry, float):
|
||||
if not math.isfinite(entry) or not entry.is_integer():
|
||||
return None
|
||||
entry = int(entry)
|
||||
if isinstance(entry, int):
|
||||
return str(entry)
|
||||
if isinstance(entry, str):
|
||||
text = entry.strip()
|
||||
return text or None
|
||||
return None
|
||||
|
||||
|
||||
def _parse_allowed_users(allowed_users: Any) -> frozenset[str] | None:
|
||||
"""Parse iLink IDs without splitting a scalar string into multiple IDs."""
|
||||
if isinstance(allowed_users, str) and ("," in allowed_users or any(char.isspace() for char in allowed_users.strip())):
|
||||
logger.warning("[WeChat] allowed_users is a scalar string containing separators; treating it as one literal user ID. Use a YAML list for multiple IDs")
|
||||
return parse_allowed_users(
|
||||
allowed_users,
|
||||
parse_user_id=_parse_wechat_user_id,
|
||||
logger=logger,
|
||||
channel_name="WeChat",
|
||||
expected_id="a valid iLink user ID",
|
||||
)
|
||||
|
||||
|
||||
class WechatChannel(Channel):
|
||||
"""WeChat iLink bot channel using long-polling.
|
||||
|
||||
@ -175,7 +211,9 @@ class WechatChannel(Channel):
|
||||
- ``bot_token``: iLink bot token used for authenticated API calls.
|
||||
- ``qrcode_login_enabled``: (optional) Allow first-time QR bootstrap when ``bot_token`` is missing.
|
||||
- ``base_url``: (optional) iLink API base URL.
|
||||
- ``allowed_users``: (optional) List of allowed iLink user IDs. Empty = allow all.
|
||||
- ``allowed_users``: (optional) List of allowed iLink user IDs, or a single
|
||||
ID. Empty = allow all; a configured value with no valid ID denies everyone.
|
||||
Integer-valued numbers are stored as integer text. A mapping is not a list of IDs.
|
||||
- ``allowed_media_hosts``: (optional) Extra host suffixes inbound media URLs may
|
||||
be downloaded from, in addition to the platform CDN defaults. Default: ``qq.com``.
|
||||
- ``polling_timeout``: (optional) Long-poll timeout in seconds. Default: 35.
|
||||
@ -285,7 +323,7 @@ class WechatChannel(Channel):
|
||||
self._max_outbound_file_bytes = self._coerce_int(config.get("max_outbound_file_bytes"), self.DEFAULT_MAX_OUTBOUND_FILE_BYTES)
|
||||
self._allowed_file_extensions = self._coerce_str_set(config.get("allowed_file_extensions"), self.DEFAULT_ALLOWED_FILE_EXTENSIONS)
|
||||
self._allowed_media_hosts = self._coerce_host_suffixes(config.get("allowed_media_hosts"))
|
||||
self._allowed_users: set[str] = {str(uid).strip() for uid in config.get("allowed_users", []) if str(uid).strip()}
|
||||
self._allowed_users: frozenset[str] | None = _parse_allowed_users(config.get("allowed_users"))
|
||||
self._bot_token = str(config.get("bot_token") or "").strip()
|
||||
self._ilink_bot_id = str(config.get("ilink_bot_id") or "").strip() or None
|
||||
self._auth_state: dict[str, Any] = {}
|
||||
@ -922,7 +960,7 @@ class WechatChannel(Channel):
|
||||
return self._context_tokens_by_chat.get(msg.chat_id)
|
||||
|
||||
def _check_user(self, user_id: str) -> bool:
|
||||
if not self._allowed_users:
|
||||
if self._allowed_users is None:
|
||||
return True
|
||||
return user_id in self._allowed_users
|
||||
|
||||
|
||||
@ -9421,7 +9421,10 @@ class TestTelegramAllowedUsers:
|
||||
|
||||
return TelegramChannel(bus=MessageBus(), config={"bot_token": "test-token", **config_extra})
|
||||
|
||||
@pytest.mark.parametrize("config_extra", [{}, {"allowed_users": None}, {"allowed_users": []}, {"allowed_users": " "}])
|
||||
@pytest.mark.parametrize(
|
||||
"config_extra",
|
||||
[{}, {"allowed_users": None}, {"allowed_users": []}, {"allowed_users": ()}, {"allowed_users": set()}, {"allowed_users": frozenset()}, {"allowed_users": " "}],
|
||||
)
|
||||
def test_unset_or_empty_allowlist_allows_everyone_without_warning(self, config_extra, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="app.channels.telegram"):
|
||||
ch = self._channel(config_extra)
|
||||
@ -9431,7 +9434,7 @@ class TestTelegramAllowedUsers:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allowed_users",
|
||||
[[123456, 7], ["123456", " 7 "], (123456, 7), {123456, 7}],
|
||||
[[123456, 7], ["123456", " 7 "], (123456, 7), {123456, 7}, frozenset({123456, 7})],
|
||||
)
|
||||
def test_numeric_ids_are_allowed_and_others_denied(self, allowed_users):
|
||||
ch = self._channel({"allowed_users": allowed_users})
|
||||
@ -9459,6 +9462,15 @@ class TestTelegramAllowedUsers:
|
||||
# 0 and -5 are numeric, so the hint has to say what they are missing.
|
||||
assert "positive numeric" in caplog.text
|
||||
|
||||
def test_frozen_allowlist_drops_invalid_entries_without_blocking_valid_ids(self, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="app.channels.telegram"):
|
||||
ch = self._channel({"allowed_users": frozenset({123456, "@alice"})})
|
||||
|
||||
assert ch._check_user(123456)
|
||||
assert not ch._check_user(42)
|
||||
assert "'@alice'" in caplog.text
|
||||
assert not any(record.levelno == logging.ERROR for record in caplog.records)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allowed_users",
|
||||
# "123456,789" is what a ``$ENV`` reference to a comma-separated value resolves to.
|
||||
|
||||
@ -395,6 +395,239 @@ def test_allowed_users_filter_blocks_non_whitelisted_sender():
|
||||
_run(go())
|
||||
|
||||
|
||||
class TestWechatAllowedUsers:
|
||||
"""A restriction the operator configured must never silently open the bot."""
|
||||
|
||||
@staticmethod
|
||||
def _channel(config_extra: dict) -> Any:
|
||||
from app.channels.wechat import WechatChannel
|
||||
|
||||
return WechatChannel(bus=MessageBus(), config={"bot_token": "test-token", **config_extra})
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_extra",
|
||||
[{}, {"allowed_users": None}, {"allowed_users": []}, {"allowed_users": ()}, {"allowed_users": set()}, {"allowed_users": frozenset()}, {"allowed_users": " "}],
|
||||
)
|
||||
def test_unset_or_empty_allowlist_allows_everyone_without_warning(self, config_extra, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="app.channels.wechat"):
|
||||
channel = self._channel(config_extra)
|
||||
|
||||
assert channel._check_user("wxid-alice")
|
||||
assert channel._check_user("wxid-bob")
|
||||
assert caplog.records == []
|
||||
|
||||
@pytest.mark.parametrize("collection", [list, tuple, set, frozenset])
|
||||
def test_listed_ids_are_allowed_and_others_denied(self, collection):
|
||||
channel = self._channel({"allowed_users": collection(["wxid-alice", "wxid-bob"])})
|
||||
|
||||
assert channel._check_user("wxid-alice")
|
||||
assert channel._check_user("wxid-bob")
|
||||
assert not channel._check_user("wxid-stranger")
|
||||
|
||||
@pytest.mark.parametrize("allowed_users", ["wxid-alice", 12345], ids=["str", "int"])
|
||||
def test_scalar_user_id_is_one_entry_not_its_characters(self, allowed_users):
|
||||
channel = self._channel({"allowed_users": allowed_users})
|
||||
|
||||
expected = str(allowed_users)
|
||||
assert channel._check_user(expected)
|
||||
assert not channel._check_user(expected[0])
|
||||
|
||||
@pytest.mark.parametrize("bad_entry", ["", " ", None, True, 1.5, float("nan"), {"wxid-bob": True}])
|
||||
def test_unparseable_entry_is_dropped_with_warning(self, bad_entry, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="app.channels.wechat"):
|
||||
channel = self._channel({"allowed_users": ["wxid-alice", bad_entry]})
|
||||
|
||||
assert channel._check_user("wxid-alice")
|
||||
assert not channel._check_user("wxid-stranger")
|
||||
assert repr(bad_entry) in caplog.text
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allowed_users",
|
||||
[[""], [None], [True], [" "], 1.5, True, {"wxid-alice": True}, {}, float("nan"), float("inf"), float("-inf")],
|
||||
)
|
||||
def test_allowlist_without_a_parseable_entry_denies_everyone(self, allowed_users, caplog):
|
||||
with caplog.at_level(logging.ERROR, logger="app.channels.wechat"):
|
||||
channel = self._channel({"allowed_users": allowed_users})
|
||||
|
||||
assert not channel._check_user("wxid-alice")
|
||||
assert not channel._check_user("wxid-bob")
|
||||
assert any(record.levelno == logging.ERROR for record in caplog.records)
|
||||
|
||||
def test_scalar_allowlist_admits_the_id_and_not_a_character(self):
|
||||
from app.channels.wechat import WechatChannel
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
published = []
|
||||
|
||||
async def capture(msg):
|
||||
published.append(msg)
|
||||
|
||||
bus.publish_inbound = capture # type: ignore[method-assign]
|
||||
channel = WechatChannel(bus=bus, config={"bot_token": "test-token", "allowed_users": "wxid-alice"})
|
||||
await channel._handle_update(
|
||||
{
|
||||
"message_type": 1,
|
||||
"from_user_id": "w",
|
||||
"context_token": "ctx-char",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "hello"}}],
|
||||
}
|
||||
)
|
||||
await channel._handle_update(
|
||||
{
|
||||
"message_type": 1,
|
||||
"from_user_id": "wxid-alice",
|
||||
"context_token": "ctx-user",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "hello"}}],
|
||||
}
|
||||
)
|
||||
assert [msg.user_id for msg in published] == ["wxid-alice"]
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_padded_id_matches_the_stripped_value(self):
|
||||
channel = self._channel({"allowed_users": [" wxid-alice "]})
|
||||
|
||||
assert channel._check_user("wxid-alice")
|
||||
assert not channel._check_user("wxid-stranger")
|
||||
|
||||
def test_integer_valued_float_is_integer_text_and_numeric_strings_stay_literal(self):
|
||||
number = self._channel({"allowed_users": 12345.0})
|
||||
assert number._check_user("12345")
|
||||
assert not number._check_user("12345.0")
|
||||
assert not number._check_user("1")
|
||||
|
||||
literal = self._channel({"allowed_users": "12345.0"})
|
||||
assert literal._check_user("12345.0")
|
||||
assert not literal._check_user("12345")
|
||||
|
||||
nan_text = self._channel({"allowed_users": "nan"})
|
||||
assert nan_text._check_user("nan")
|
||||
|
||||
def test_set_of_ids_allows_members_and_mapping_does_not_allow_its_keys(self):
|
||||
listed = self._channel({"allowed_users": {"wxid-alice"}})
|
||||
assert listed._check_user("wxid-alice")
|
||||
assert not listed._check_user("wxid-stranger")
|
||||
|
||||
mapping = self._channel({"allowed_users": {"wxid-alice": True}})
|
||||
assert mapping._allowed_users == frozenset()
|
||||
assert not mapping._check_user("wxid-alice")
|
||||
|
||||
@pytest.mark.parametrize("separator", [",", ", ", " ", "\t", "\n", "\u2003"])
|
||||
def test_scalar_ids_with_separators_warn_but_remain_one_id(self, separator, caplog):
|
||||
literal_id = f"wxid-alice{separator}wxid-bob"
|
||||
with caplog.at_level(logging.WARNING, logger="app.channels.wechat"):
|
||||
channel = self._channel({"allowed_users": literal_id})
|
||||
|
||||
assert channel._check_user(literal_id)
|
||||
assert not channel._check_user("wxid-alice")
|
||||
assert not channel._check_user("wxid-bob")
|
||||
assert not channel._check_user("w")
|
||||
assert "one literal user ID" in caplog.text
|
||||
assert "YAML list" in caplog.text
|
||||
assert "wxid-alice" not in caplog.text
|
||||
assert "wxid-bob" not in caplog.text
|
||||
|
||||
@pytest.mark.parametrize("user_id", ["wxid-alice", " \twxid-alice\n "])
|
||||
def test_scalar_id_padding_does_not_warn(self, user_id, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="app.channels.wechat"):
|
||||
channel = self._channel({"allowed_users": user_id})
|
||||
|
||||
assert channel._check_user("wxid-alice")
|
||||
assert caplog.records == []
|
||||
|
||||
def test_gate_uses_ilink_user_id_and_skips_media_for_strangers(self):
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
published = []
|
||||
|
||||
async def capture(msg):
|
||||
published.append(msg)
|
||||
|
||||
bus.publish_inbound = capture # type: ignore[method-assign]
|
||||
channel = self._channel({"allowed_users": ["wxid-alice"]})
|
||||
channel.bus = bus
|
||||
channel._extract_inbound_files = AsyncMock(return_value=[]) # type: ignore[method-assign]
|
||||
stranger = {
|
||||
"message_type": 1,
|
||||
"ilink_user_id": "wxid-stranger",
|
||||
"context_token": "ctx-stranger",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "hello"}}, {"type": 2, "image_item": {"media": {}}}],
|
||||
}
|
||||
member = {
|
||||
"message_type": 1,
|
||||
"ilink_user_id": "wxid-alice",
|
||||
"context_token": "ctx-alice",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "hello"}}],
|
||||
}
|
||||
await channel._handle_update(stranger)
|
||||
await channel._handle_update(member)
|
||||
|
||||
assert [msg.user_id for msg in published] == ["wxid-alice"]
|
||||
channel._extract_inbound_files.assert_awaited_once_with(member)
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_unreadable_allowlist_drops_chat_and_command_before_media_download(self):
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
published = []
|
||||
|
||||
async def capture(msg):
|
||||
published.append(msg)
|
||||
|
||||
bus.publish_inbound = capture # type: ignore[method-assign]
|
||||
channel = self._channel({"allowed_users": [""]})
|
||||
channel.bus = bus
|
||||
channel._extract_inbound_files = AsyncMock(return_value=[{"name": "pic"}]) # type: ignore[method-assign]
|
||||
for text in ("hello", "/status"):
|
||||
await channel._handle_update(
|
||||
{
|
||||
"message_type": 1,
|
||||
"from_user_id": "wxid-stranger",
|
||||
"context_token": "ctx-denied",
|
||||
"item_list": [{"type": 1, "text_item": {"text": text}}, {"type": 2, "image_item": {"media": {}}}],
|
||||
}
|
||||
)
|
||||
|
||||
assert published == []
|
||||
channel._extract_inbound_files.assert_not_awaited()
|
||||
|
||||
_run(go())
|
||||
|
||||
def test_connect_code_is_consumed_when_allowlist_denies_everyone(self):
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
published = []
|
||||
|
||||
async def capture(msg):
|
||||
published.append(msg)
|
||||
|
||||
bus.publish_inbound = capture # type: ignore[method-assign]
|
||||
channel = self._channel({"allowed_users": [""], "connection_repo": object()})
|
||||
channel.bus = bus
|
||||
channel._bind_connection_from_connect_code = AsyncMock(return_value=True) # type: ignore[method-assign]
|
||||
channel._extract_inbound_files = AsyncMock(return_value=[{"name": "pic"}]) # type: ignore[method-assign]
|
||||
await channel._handle_update(
|
||||
{
|
||||
"message_type": 1,
|
||||
"from_user_id": "wxid-stranger",
|
||||
"context_token": "ctx-connect",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "/connect one-time-fixture"}}],
|
||||
}
|
||||
)
|
||||
|
||||
channel._bind_connection_from_connect_code.assert_awaited_once_with(
|
||||
chat_id="wxid-stranger",
|
||||
context_token="ctx-connect",
|
||||
code="one-time-fixture",
|
||||
)
|
||||
channel._extract_inbound_files.assert_not_awaited()
|
||||
assert published == []
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_connect_code_bypasses_allowed_users_filter(tmp_path: Path):
|
||||
from app.channels.wechat import WechatChannel
|
||||
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
|
||||
|
||||
@ -2977,7 +2977,7 @@ run_ownership:
|
||||
# ilink_app_id: ""
|
||||
# # Optional: sent as SKRouteTag header when provided
|
||||
# route_tag: ""
|
||||
# allowed_users: [] # empty = allow all
|
||||
# allowed_users: [] # iLink user IDs; empty allows all; a single ID is one entry; unparseable non-empty lists fail closed
|
||||
# # Optional: extra host suffixes inbound media downloads may come from,
|
||||
# # in addition to the platform CDN defaults (*.qq.com and the cdn_base_url host).
|
||||
# # Entries are suffixes: "example.com" and "*.example.com" are equivalent.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user