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:
carfeii
2026-10-05 22:00:54 +08:00
parent dcc7e17d77
commit 7b7f1d20f4
3 changed files with 131 additions and 7 deletions
@@ -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)
+27 -2
View File
@@ -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