From a97772d016f37fab1aa02a98afaef8fa16cb810e Mon Sep 17 00:00:00 2001 From: Shxiao Date: Tue, 6 Oct 2026 22:17:11 +0900 Subject: [PATCH] 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 --- README.md | 3 +- backend/app/channels/AGENTS.md | 7 +- backend/app/channels/allowed_users.py | 41 +++++ backend/app/channels/telegram.py | 32 ++-- backend/app/channels/wechat.py | 44 ++++- backend/tests/test_channels.py | 16 +- backend/tests/test_wechat_channel.py | 233 ++++++++++++++++++++++++++ config.example.yaml | 2 +- 8 files changed, 346 insertions(+), 32 deletions(-) create mode 100644 backend/app/channels/allowed_users.py diff --git a/README.md b/README.md index d2cc32fc60..96fc2e9e10 100644 --- a/README.md +++ b/README.md @@ -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** diff --git a/backend/app/channels/AGENTS.md b/backend/app/channels/AGENTS.md index 15655970ef..03b53dbacd 100644 --- a/backend/app/channels/AGENTS.md +++ b/backend/app/channels/AGENTS.md @@ -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 ` flow over the existing long-polling worker. Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom use `/connect ` 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 ` over long polling; Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom use `/connect ` 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 ` (or Telegram `/start `) **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 ` (Telegram `/start `) 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). diff --git a/backend/app/channels/allowed_users.py b/backend/app/channels/allowed_users.py new file mode 100644 index 0000000000..0d8d8e9062 --- /dev/null +++ b/backend/app/channels/allowed_users.py @@ -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) diff --git a/backend/app/channels/telegram.py b/backend/app/channels/telegram.py index 25765cf7b8..de458e0c16 100644 --- a/backend/app/channels/telegram.py +++ b/backend/app/channels/telegram.py @@ -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): diff --git a/backend/app/channels/wechat.py b/backend/app/channels/wechat.py index ee3a3397a8..f6dd43118a 100644 --- a/backend/app/channels/wechat.py +++ b/backend/app/channels/wechat.py @@ -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 diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py index 0daf7bb540..698a291493 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -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. diff --git a/backend/tests/test_wechat_channel.py b/backend/tests/test_wechat_channel.py index 008cccc715..3adf89e7a3 100644 --- a/backend/tests/test_wechat_channel.py +++ b/backend/tests/test_wechat_channel.py @@ -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 diff --git a/config.example.yaml b/config.example.yaml index d288a1d5af..c5ebc7b786 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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.