diff --git a/src/langbot/pkg/platform/adapters/lark/adapter.py b/src/langbot/pkg/platform/adapters/lark/adapter.py index 776c0b7b0..b3af18e3e 100644 --- a/src/langbot/pkg/platform/adapters/lark/adapter.py +++ b/src/langbot/pkg/platform/adapters/lark/adapter.py @@ -9,6 +9,7 @@ import threading import asyncio import base64 import hashlib +import hmac import json import time import traceback @@ -629,9 +630,34 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte return f'{event.group.id}_{thread_id}' return None + def _verify_webhook_signature(self, request, raw_body: bytes) -> bool: + """Verify the Lark/Feishu event-subscription callback signature. + + Per Lark's own event-security-verification protocol, a callback is + authentic only if its ``X-Lark-Signature`` header equals + sha256(timestamp + nonce + encrypt_key + body). Without this check + any caller who reaches this webhook path can inject fabricated + events (spoofed messages, card actions) regardless of whether they + hold the app's Encrypt Key. + """ + encrypt_key = self.config.get('encrypt-key', '') or '' + if not encrypt_key: + return False + timestamp = request.headers.get('X-Lark-Request-Timestamp', '') + nonce = request.headers.get('X-Lark-Request-Nonce', '') + signature = request.headers.get('X-Lark-Signature', '') + if not timestamp or not nonce or not signature: + return False + expected = hashlib.sha256((timestamp + nonce + encrypt_key).encode('utf-8') + raw_body).hexdigest() + return hmac.compare_digest(expected, signature) + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): try: - data = await request.json + raw_body = await request.get_data() + if 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) 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/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index b4c4dc827..41822fff7 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -5,6 +5,7 @@ from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody, Crea import traceback import typing import asyncio +import hmac import re import base64 import uuid @@ -2973,6 +2974,27 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): else: return data['type'] + def _verify_webhook_signature(self, request, raw_body: bytes) -> bool: + """Verify the Lark/Feishu event-subscription callback signature. + + Per Lark's event-security-verification protocol, a callback is + authentic only if its ``X-Lark-Signature`` header equals + sha256(timestamp + nonce + encrypt_key + body). Without this check + any caller who reaches this webhook path can inject fabricated + events (spoofed messages, card actions) regardless of whether they + hold the app's Encrypt Key. + """ + encrypt_key = self.config.get('encrypt-key', '') or '' + if not encrypt_key: + return False + timestamp = request.headers.get('X-Lark-Request-Timestamp', '') + nonce = request.headers.get('X-Lark-Request-Nonce', '') + signature = request.headers.get('X-Lark-Signature', '') + if not timestamp or not nonce or not signature: + return False + expected = hashlib.sha256((timestamp + nonce + encrypt_key).encode('utf-8') + raw_body).hexdigest() + return hmac.compare_digest(expected, signature) + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): """处理统一 webhook 请求。 Args: @@ -2983,7 +3005,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 响应数据 """ try: - data = await request.json + raw_body = await request.get_data() + if 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) if 'encrypt' in data: encrypted = data['encrypt'] @@ -2991,7 +3017,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): type = self.get_event_type(data) context = EventContext(data) if 'url_verification' == type: - # todo 验证verification token return {'challenge': data.get('challenge')} elif 'app_ticket' == type: self.app_ticket = context.event['app_ticket'] diff --git a/tests/unit_tests/platform/test_lark_eba_adapter.py b/tests/unit_tests/platform/test_lark_eba_adapter.py index 827a2f4f6..ac3a50184 100644 --- a/tests/unit_tests/platform/test_lark_eba_adapter.py +++ b/tests/unit_tests/platform/test_lark_eba_adapter.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import pathlib import asyncio @@ -159,6 +160,26 @@ def make_adapter(config: dict | None = None) -> LarkAdapter: return adapter +def signed_webhook_request(payload: dict, encrypt_key: str) -> SimpleNamespace: + """Build a Quart-request-like double carrying a valid X-Lark-Signature. + + Mirrors Lark's documented event-security-verification scheme: + sha256(timestamp + nonce + encrypt_key + body). + """ + body = json.dumps(payload).encode('utf-8') + timestamp = '1714000000' + nonce = 'nonce-1' + signature = hashlib.sha256((timestamp + nonce + encrypt_key).encode('utf-8') + body).hexdigest() + return SimpleNamespace( + get_data=lambda: asyncio.sleep(0, result=body), + headers={ + 'X-Lark-Request-Timestamp': timestamp, + 'X-Lark-Request-Nonce': nonce, + 'X-Lark-Signature': signature, + }, + ) + + def lark_event(chat_type='group', message_type='text', content=None): message = SimpleNamespace( message_id='msg-1', @@ -849,7 +870,7 @@ def test_lark_native_form_callback_submits_typed_field_value(): @pytest.mark.asyncio async def test_lark_webhook_dispatches_native_form_submission(): - adapter = make_adapter({'enable-webhook': True}) + adapter = make_adapter({'enable-webhook': True, 'encrypt-key': 'test-encrypt-key'}) calls: list[platform_events.Event] = [] async def listener(event, adapter): @@ -873,7 +894,7 @@ async def test_lark_webhook_dispatches_native_form_submission(): 'context': {'open_chat_id': 'chat-1', 'open_message_id': 'card-message-1'}, }, } - request = SimpleNamespace(json=asyncio.sleep(0, result=payload)) + request = signed_webhook_request(payload, 'test-encrypt-key') response = await adapter.handle_unified_webhook('bot-1', '', request) @@ -889,7 +910,7 @@ async def test_lark_webhook_dispatches_native_form_submission(): @pytest.mark.asyncio async def test_lark_webhook_cardkit_submission_uses_async_card_update_only(): - adapter = make_adapter({'enable-webhook': True}) + adapter = make_adapter({'enable-webhook': True, 'encrypt-key': 'test-encrypt-key'}) async def listener(event, adapter): pass @@ -904,8 +925,60 @@ async def test_lark_webhook_cardkit_submission_uses_async_card_update_only(): 'context': {'open_chat_id': 'chat-1', 'open_message_id': 'card-message-1'}, }, } - request = SimpleNamespace(json=asyncio.sleep(0, result=payload)) + request = signed_webhook_request(payload, 'test-encrypt-key') response = await adapter.handle_unified_webhook('bot-1', '', request) assert response == {'toast': {'type': 'success', 'content': 'Submitted / 已提交'}} + + +@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.""" + adapter = make_adapter({'enable-webhook': True}) + calls: list[platform_events.Event] = [] + + async def listener(event, adapter): + calls.append(event) + + adapter.register_listener(platform_events.PlatformSpecificEvent, listener) + payload = { + 'schema': '2.0', + 'header': {'event_type': 'im.message.receive_v1'}, + 'event': { + 'message': {'message_id': 'spoofed-msg', 'chat_id': 'chat-1', 'chat_type': 'p2p', 'content': '{}'}, + 'sender': {'sender_id': {'open_id': 'attacker'}}, + }, + } + request = SimpleNamespace( + get_data=lambda: asyncio.sleep(0, result=json.dumps(payload).encode('utf-8')), + headers={}, + ) + + response, status = await adapter.handle_unified_webhook('bot-1', '', request) + + assert status == 403 + assert calls == [] + + +@pytest.mark.asyncio +async def test_lark_webhook_rejects_wrong_signature(): + """A caller who knows the webhook URL but not the Encrypt Key must not + be able to forge a valid X-Lark-Signature and inject a card action.""" + adapter = make_adapter({'enable-webhook': True, 'encrypt-key': 'real-encrypt-key'}) + payload = { + 'schema': '2.0', + 'header': {'event_type': 'card.action.trigger'}, + 'event': { + 'action': {'value': {'lbi': 'callback-token', 't': 'group', 'ck': 1, 'a': 0}}, + 'operator': {'open_id': 'user-1'}, + 'context': {'open_chat_id': 'chat-1', 'open_message_id': 'card-message-1'}, + }, + } + request = signed_webhook_request(payload, 'wrong-encrypt-key') + + response, status = await adapter.handle_unified_webhook('bot-1', '', request) + + assert status == 403