mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
fix(lark): verify webhook event-subscription signature before dispatch
The Lark/Feishu webhook handler (both the current lark-omni adapter and the legacy lark adapter) parsed and dispatched inbound HTTP callback events without checking Lark's own X-Lark-Signature header, and the Encrypt Key config field used to decrypt payloads is optional and empty by default. Any caller who reached a bot's webhook URL could inject fabricated message/card-action events with an attacker-chosen sender, chat, and content. Add signature verification matching Lark's documented event-security-verification scheme (sha256 of timestamp + nonce + encrypt_key + body, compared to X-Lark-Signature) to both implementations, rejecting any callback that lacks a valid signature or whose bot has no Encrypt Key configured. Adds regression tests covering the rejection paths and updates the two existing webhook tests to present a valid signature.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user