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']