Merge pull request #2628 from carfeii/fix/lark-webhook-signature-verification

fix(lark): verify webhook event-subscription signature before dispatch
This commit is contained in:
RockChinQ
2026-10-19 02:04:37 +08:00
committed by GitHub
6 changed files with 374 additions and 8 deletions
@@ -11,6 +11,7 @@ import threading
import asyncio
import base64
import hashlib
import hmac
import json
import time
import traceback
@@ -638,10 +639,56 @@ 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)
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'))
@localized_adapter
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
try:
data = await request.json
raw_body = await request.get_data()
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
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)
@@ -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: ""
@@ -140,12 +150,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: ""
@@ -173,6 +206,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
@@ -195,6 +233,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: ""
+48 -2
View File
@@ -7,6 +7,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
@@ -3003,6 +3004,39 @@ 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)
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'))
@localized_adapter
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""处理统一 webhook 请求。
@@ -3014,7 +3048,20 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
响应数据
"""
try:
data = await request.json
raw_body = await request.get_data()
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
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']
@@ -3022,7 +3069,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']
+19 -1
View File
@@ -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
@@ -1,5 +1,6 @@
from __future__ import annotations
import hashlib
import json
import pathlib
import asyncio
@@ -160,6 +161,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',
@@ -853,7 +874,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):
@@ -877,7 +898,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)
@@ -893,7 +914,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
@@ -908,13 +929,66 @@ 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 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] = []
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
def test_lark_stream_card_separates_multiple_reasoning_rounds():
from langbot.pkg.platform.adapters.lark.stream_card import build_card, streaming_text
content = '<think>first thought</think>first answer<think>second thought</think>final answer'
@@ -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