diff --git a/src/langbot/pkg/platform/adapters/lark/adapter.py b/src/langbot/pkg/platform/adapters/lark/adapter.py index b3af18e3e..6c8b9503f 100644 --- a/src/langbot/pkg/platform/adapters/lark/adapter.py +++ b/src/langbot/pkg/platform/adapters/lark/adapter.py @@ -651,13 +651,34 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte expected = hashlib.sha256((timestamp + nonce + encrypt_key).encode('utf-8') + raw_body).hexdigest() return hmac.compare_digest(expected, signature) + def _verify_webhook_token(self, data) -> bool: + """Authenticate plaintext callbacks when encryption is not configured.""" + expected = self.config.get('verification-token', '') + if not isinstance(expected, str) or not expected or not isinstance(data, dict) or 'encrypt' in data: + return False + if data.get('schema') == '2.0': + header = data.get('header') + token = header.get('token') if isinstance(header, dict) else None + else: + token = data.get('token') + return isinstance(token, str) and hmac.compare_digest(expected.encode('utf-8'), token.encode('utf-8')) + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): try: raw_body = await request.get_data() - if not self._verify_webhook_signature(request, raw_body): + encrypted_mode = bool(self.config.get('encrypt-key')) + if encrypted_mode and not self._verify_webhook_signature(request, raw_body): await self.logger.error('Lark webhook signature verification failed, rejecting callback') return {'code': 403, 'message': 'signature verification failed'}, 403 - data = json.loads(raw_body) + try: + data = json.loads(raw_body) + except (ValueError, UnicodeDecodeError): + return {'code': 400, 'message': 'invalid JSON body'}, 400 + if not isinstance(data, dict): + return {'code': 400, 'message': 'invalid callback body'}, 400 + if not encrypted_mode and not self._verify_webhook_token(data): + await self.logger.error('Lark webhook token verification failed, rejecting callback') + return {'code': 403, 'message': 'token verification failed'}, 403 if 'encrypt' in data: data = json.loads(self.cipher.decrypt_string(data['encrypt'])) event_type = self.get_event_type(data) diff --git a/src/langbot/pkg/platform/adapters/lark/manifest.yaml b/src/langbot/pkg/platform/adapters/lark/manifest.yaml index 5d80781ca..b60a04904 100644 --- a/src/langbot/pkg/platform/adapters/lark/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/lark/manifest.yaml @@ -96,6 +96,11 @@ spec: zh_Hans: 应用ID zh_Hant: 應用ID ja_JP: アプリ ID + description: + en_US: Copy App ID from Credentials & Basic Info in the Lark developer console. Identifies the app used by this bot. + zh_Hans: 从飞书开放平台应用的「凭证与基础信息」复制 App ID,用于标识此机器人使用的应用。 + zh_Hant: 從飛書開放平台應用的「憑證與基礎資訊」複製 App ID,用於識別此機器人使用的應用。 + ja_JP: Lark 開発者コンソールの認証情報と基本情報から App ID をコピーします。このボットが使用するアプリを識別します。 type: string required: true default: "" @@ -105,6 +110,11 @@ spec: zh_Hans: 应用密钥 zh_Hant: 應用密鑰 ja_JP: アプリシークレット + description: + en_US: Copy App Secret from the same app's Credentials & Basic Info. Used with App ID to obtain API access tokens; this is not the webhook Encrypt Key. + zh_Hans: 从同一应用的「凭证与基础信息」复制 App Secret,与 App ID 配合获取 API 访问令牌;它不是 Webhook 的 Encrypt Key。 + zh_Hant: 從同一應用的「憑證與基礎資訊」複製 App Secret,與 App ID 配合取得 API 存取權杖;它不是 Webhook 的 Encrypt Key。 + ja_JP: 同じアプリの認証情報と基本情報から App Secret をコピーします。App ID と共に API アクセストークンを取得するために使用します。Webhook の Encrypt Key とは異なります。 type: string required: true default: "" @@ -154,12 +164,35 @@ spec: field: enable-webhook operator: eq value: true + - name: verification-token + label: + en_US: Verification Token + zh_Hans: 验证令牌(Verification Token) + zh_Hant: 驗證權杖(Verification Token) + ja_JP: 検証トークン(Verification Token) + description: + en_US: Copy Verification Token from Events & Callbacks > Encryption Strategy in the Lark developer console. Required when Encrypt Key is empty; plaintext callbacks are accepted only when their token matches. A token cannot replace signature verification when Encrypt Key is set. + zh_Hans: 从飞书开放平台应用的「事件与回调 → 加密策略」复制 Verification Token。Encrypt Key 为空时必填,明文回调仅在令牌一致时接受;设置 Encrypt Key 后仍必须通过签名校验,不能用此令牌替代。 + zh_Hant: 從飛書開放平台應用的「事件與回調 → 加密策略」複製 Verification Token。Encrypt Key 為空時必填,明文回調僅在權杖一致時接受;設定 Encrypt Key 後仍必須通過簽章驗證,不能以此權杖替代。 + ja_JP: Lark 開発者コンソールのイベントとコールバックの暗号化設定から Verification Token をコピーします。Encrypt Key が空の場合は必須で、トークンが一致する平文コールバックのみ受け付けます。Encrypt Key 設定時の署名検証は代替できません。 + type: string + required: false + default: "" + show_if: + field: enable-webhook + operator: eq + value: true - name: encrypt-key label: en_US: Encrypt Key zh_Hans: 加密密钥 zh_Hant: 加密密鑰 ja_JP: 暗号化キー + description: + en_US: Copy Encrypt Key from Events & Callbacks > Encryption Strategy in the Lark developer console. Both sides must match. Used to verify callback signatures and decrypt encrypted payloads. If left empty on both sides, configure Verification Token for plaintext callbacks. + zh_Hans: 从飞书开放平台应用的「事件与回调 → 加密策略」复制 Encrypt Key,两边必须一致,用于回调签名校验和加密正文解密。两边均留空时,需填写 Verification Token 校验明文回调。 + zh_Hant: 從飛書開放平台應用的「事件與回調 → 加密策略」複製 Encrypt Key,兩邊必須一致,用於回調簽章驗證與加密本文解密。兩邊均留空時,需填寫 Verification Token 驗證明文回調。 + ja_JP: Lark 開発者コンソールのイベントとコールバックの暗号化設定から Encrypt Key をコピーし、両側で一致させます。署名検証と暗号化本文の復号に使用します。両側で空にする場合は、平文コールバック検証用の Verification Token を設定してください。 type: string required: false default: "" @@ -187,6 +220,11 @@ spec: zh_Hans: 应用类型 zh_Hant: 應用類型 ja_JP: アプリタイプ + description: + en_US: Match the app type in the Lark developer console. Choose Self-built Application for an internal app, or Store Application for an ISV app distributed through the app store. + zh_Hans: 与飞书开放平台中的应用类型保持一致;企业内部使用的应用选择「自建应用」,通过应用商店分发的 ISV 应用选择「商店应用」。 + zh_Hant: 與飛書開放平台中的應用類型保持一致;企業內部使用的應用選擇「自建應用」,透過應用商店發佈的 ISV 應用選擇「商店應用」。 + ja_JP: Lark 開発者コンソールのアプリ種別に合わせます。社内用はカスタムアプリ、アプリストアで配布する ISV アプリはストアアプリを選択してください。 type: select options: - name: self @@ -209,6 +247,11 @@ spec: zh_Hans: 机器人进群欢迎语 zh_Hant: 機器人進群歡迎語 ja_JP: ボット参加時のウェルカムメッセージ + description: + en_US: Optional text to send when the bot joins a group. Enter your own welcome message and subscribe to im.chat.member.bot.added_v1 in Lark. Leave empty to disable. + zh_Hans: 机器人加入群聊时发送的可选文本,可自行填写欢迎语,并在飞书订阅 im.chat.member.bot.added_v1 事件;留空则不发送。 + zh_Hant: 機器人加入群聊時傳送的選填文字,可自行填寫歡迎語,並在飛書訂閱 im.chat.member.bot.added_v1 事件;留空則不傳送。 + ja_JP: ボットがグループに参加した際に送信する任意のメッセージです。文面を入力し、Lark で im.chat.member.bot.added_v1 を購読してください。空欄の場合は送信しません。 type: text required: false default: "" diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index 41822fff7..a1672045e 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -2995,6 +2995,18 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): expected = hashlib.sha256((timestamp + nonce + encrypt_key).encode('utf-8') + raw_body).hexdigest() return hmac.compare_digest(expected, signature) + def _verify_webhook_token(self, data) -> bool: + """Authenticate plaintext callbacks when encryption is not configured.""" + expected = self.config.get('verification-token', '') + if not isinstance(expected, str) or not expected or not isinstance(data, dict) or 'encrypt' in data: + return False + if data.get('schema') == '2.0': + header = data.get('header') + token = header.get('token') if isinstance(header, dict) else None + else: + token = data.get('token') + return isinstance(token, str) and hmac.compare_digest(expected.encode('utf-8'), token.encode('utf-8')) + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): """处理统一 webhook 请求。 Args: @@ -3006,10 +3018,19 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): """ try: raw_body = await request.get_data() - if not self._verify_webhook_signature(request, raw_body): + encrypted_mode = bool(self.config.get('encrypt-key')) + if encrypted_mode and not self._verify_webhook_signature(request, raw_body): await self.logger.error('Lark webhook signature verification failed, rejecting callback') return {'code': 403, 'message': 'signature verification failed'}, 403 - data = json.loads(raw_body) + try: + data = json.loads(raw_body) + except (ValueError, UnicodeDecodeError): + return {'code': 400, 'message': 'invalid JSON body'}, 400 + if not isinstance(data, dict): + return {'code': 400, 'message': 'invalid callback body'}, 400 + if not encrypted_mode and not self._verify_webhook_token(data): + await self.logger.error('Lark webhook token verification failed, rejecting callback') + return {'code': 403, 'message': 'token verification failed'}, 403 if 'encrypt' in data: encrypted = data['encrypt'] diff --git a/src/langbot/pkg/platform/sources/lark.yaml b/src/langbot/pkg/platform/sources/lark.yaml index 57c5e757f..e2b9d0273 100644 --- a/src/langbot/pkg/platform/sources/lark.yaml +++ b/src/langbot/pkg/platform/sources/lark.yaml @@ -153,6 +153,24 @@ spec: field: enable-webhook operator: eq value: true + - name: verification-token + label: + en_US: Verification Token + zh_Hans: 验证令牌(Verification Token) + zh_Hant: 驗證權杖(Verification Token) + ja_JP: 検証トークン(Verification Token) + description: + en_US: Copy from Lark event subscription settings. Required when Encrypt Key is empty. + zh_Hans: 从飞书事件订阅配置复制;Encrypt Key 为空时必填。 + zh_Hant: 從飛書事件訂閱設定複製;Encrypt Key 為空時必填。 + ja_JP: Lark イベント購読設定からコピーしてください。Encrypt Key が空の場合は必須です。 + type: string + required: false + default: "" + show_if: + field: enable-webhook + operator: eq + value: true - name: encrypt-key label: en_US: Encrypt Key @@ -165,7 +183,7 @@ spec: zh_Hant: 僅在啟用 Webhook 模式時有效,請填寫加密密鑰 ja_JP: Webhook モードが有効な場合にのみ有効です。暗号化キーを入力してください type: string - required: true + required: false default: "" show_if: field: enable-webhook diff --git a/tests/unit_tests/platform/test_lark_eba_adapter.py b/tests/unit_tests/platform/test_lark_eba_adapter.py index ac3a50184..b11bed65c 100644 --- a/tests/unit_tests/platform/test_lark_eba_adapter.py +++ b/tests/unit_tests/platform/test_lark_eba_adapter.py @@ -934,9 +934,10 @@ async def test_lark_webhook_cardkit_submission_uses_async_card_update_only(): @pytest.mark.asyncio async def test_lark_webhook_rejects_unsigned_callback_with_no_encrypt_key(): - """A bot with Webhook Mode on but no Encrypt Key set must not process - inbound events at all, since there is no way to authenticate the - caller. Spoofed message.received events must never reach a listener.""" + """A bot without either callback credential must reject inbound events. + + Spoofed message.received events must never reach a listener. + """ adapter = make_adapter({'enable-webhook': True}) calls: list[platform_events.Event] = [] diff --git a/tests/unit_tests/platform/test_lark_webhook_auth.py b/tests/unit_tests/platform/test_lark_webhook_auth.py new file mode 100644 index 000000000..8d04b2801 --- /dev/null +++ b/tests/unit_tests/platform/test_lark_webhook_auth.py @@ -0,0 +1,138 @@ +"""Exercise both webhook entry points without contacting Lark.""" + +import hashlib +import json +from types import MethodType, SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot.pkg.platform.adapters.lark.adapter import LarkAdapter +from langbot.pkg.platform.sources.lark import LarkAdapter as LegacyLarkAdapter + + +@pytest.fixture(params=[LarkAdapter, LegacyLarkAdapter], ids=['native', 'legacy']) +def adapter(request): + instance = SimpleNamespace( + config={'verification-token': 'test-token'}, + logger=SimpleNamespace(error=AsyncMock()), + cipher=SimpleNamespace(decrypt_string=Mock()), + _dispatch_eba_event=AsyncMock(), + listeners={}, + app_ticket=None, + ) + for name in ['_verify_webhook_signature', '_verify_webhook_token', 'get_event_type', 'handle_unified_webhook']: + setattr(instance, name, MethodType(getattr(request.param, name), instance)) + instance._webhook_event = lambda data: data.get('event', {}) + return instance + + +def callback(payload, signing_key=None): + body = json.dumps(payload).encode() + headers = {} + if signing_key: + headers = { + 'X-Lark-Request-Timestamp': '1714000000', + 'X-Lark-Request-Nonce': 'test-nonce', + 'X-Lark-Signature': hashlib.sha256(('1714000000test-nonce' + signing_key).encode() + body).hexdigest(), + } + return SimpleNamespace(get_data=AsyncMock(return_value=body), headers=headers) + + +@pytest.mark.asyncio +async def test_plaintext_challenge(adapter): + result = await adapter.handle_unified_webhook( + '', + '', + callback( + { + 'type': 'url_verification', + 'token': 'test-token', + 'challenge': 'challenge-value', + } + ), + ) + assert result == {'challenge': 'challenge-value'} + adapter.cipher.decrypt_string.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('schema', ['1.0', '2.0']) +async def test_plaintext_authenticated_event(adapter, schema): + payload = {'event': {'type': 'app_ticket', 'app_ticket': 'new-ticket'}, 'token': 'test-token'} + if schema == '2.0': + payload = { + 'schema': schema, + 'header': {'event_type': 'app_ticket', 'token': 'test-token'}, + 'event': {'app_ticket': 'new-ticket'}, + } + await adapter.handle_unified_webhook('', '', callback(payload)) + assert adapter.app_ticket == 'new-ticket' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('token', [None, '', 'wrong', 123, {}, '非ASCII']) +@pytest.mark.parametrize('schema', ['1.0', '2.0']) +async def test_reject_invalid_token(adapter, token, schema): + payload = {'type': 'url_verification', 'token': token, 'challenge': 'must-not-return'} + if schema == '2.0': + payload = { + 'schema': schema, + 'header': {'event_type': 'app_ticket', 'token': token}, + 'event': {'app_ticket': 'must-not-set'}, + 'token': 'test-token', + } + result = await adapter.handle_unified_webhook('', '', callback(payload)) + assert result[1] == 403 + assert adapter.app_ticket is None + adapter._dispatch_eba_event.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reject_unconfigured_credentials(adapter): + adapter.config = {} + result = await adapter.handle_unified_webhook('', '', callback({'type': 'url_verification', 'token': ''})) + assert result[1] == 403 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('signing_key', [None, 'wrong-key']) +async def test_encrypted_mode_never_falls_back_to_token(adapter, signing_key): + adapter.config['encrypt-key'] = 'configured-key' + result = await adapter.handle_unified_webhook( + '', + '', + callback( + { + 'type': 'url_verification', + 'token': 'test-token', + 'challenge': 'must-not-return', + }, + signing_key, + ), + ) + assert result[1] == 403 + adapter.cipher.decrypt_string.assert_not_called() + + +@pytest.mark.asyncio +async def test_signed_encrypted_challenge(adapter): + adapter.config['encrypt-key'] = 'configured-key' + adapter.cipher.decrypt_string.return_value = json.dumps({'type': 'url_verification', 'challenge': 'accepted'}) + result = await adapter.handle_unified_webhook('', '', callback({'encrypt': 'ciphertext'}, 'configured-key')) + assert result == {'challenge': 'accepted'} + adapter.cipher.decrypt_string.assert_called_once_with('ciphertext') + + +@pytest.mark.asyncio +async def test_plaintext_mode_rejects_encrypted_envelope(adapter): + result = await adapter.handle_unified_webhook('', '', callback({'encrypt': 'ciphertext', 'token': 'test-token'})) + assert result[1] == 403 + adapter.cipher.decrypt_string.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('body', [b'{', b'[]', b'null', b'"text"', b'\xff']) +async def test_invalid_json_body(adapter, body): + result = await adapter.handle_unified_webhook('', '', SimpleNamespace(get_data=AsyncMock(return_value=body))) + assert result[1] == 400