mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-08 20:30:59 +00:00
9df021eb8f
Telegram file.file_path is a full URL of the form https://api.telegram.org/file/bot<TOKEN>/<path> that embeds the bot token. Since #2362 this URL was copied into Image.url, so the token was serialized into the message chain and thereby persisted to the monitoring database, shown in the dashboard, and forwarded to every installed plugin via event dispatch. Anyone with dashboard or plugin access could recover the token and take full control of the bot. Unlike the public CDN URLs used by the other adapters changed in #2362, Telegram file URLs are only usable with the embedded token, so there is no safe URL to expose. Store base64 only (as before #2362); the vision path already relies solely on base64, so nothing downstream changes. Add a regression test asserting the token never appears in the converted Image or the serialized message chain. Co-authored-by: Constantine1916 <Constantine1916@users.noreply.github.com>
248 lines
8.6 KiB
Python
248 lines
8.6 KiB
Python
"""Tests for Telegram Dify form callback helpers."""
|
|
|
|
import json
|
|
from contextlib import asynccontextmanager
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from telegram import ForceReply
|
|
|
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
|
|
from langbot.pkg.platform.sources.telegram import (
|
|
TelegramAdapter,
|
|
TelegramMessageConverter,
|
|
_decode_telegram_base64_limited,
|
|
_telegram_form_action_from_callback,
|
|
_telegram_select_field_options,
|
|
)
|
|
|
|
|
|
def test_telegram_base64_decode_is_bounded(monkeypatch):
|
|
import langbot.pkg.platform.sources.telegram as telegram_module
|
|
|
|
monkeypatch.setattr(telegram_module, '_MAX_TELEGRAM_MEDIA_BYTES', 4)
|
|
|
|
with pytest.raises(ValueError, match='exceeds'):
|
|
_decode_telegram_base64_limited('A' * 12)
|
|
|
|
|
|
TELEGRAM_BOT_TOKEN = '123456789:AAExampleBotTokenThatMustNotLeak'
|
|
TELEGRAM_FILE_URL = f'https://api.telegram.org/file/bot{TELEGRAM_BOT_TOKEN}/photos/file_0.jpg'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_telegram_photo_does_not_expose_bot_token_in_image_url():
|
|
"""Regression test for the Telegram bot-token leak.
|
|
|
|
telegram.Bot builds file.file_path as
|
|
https://api.telegram.org/file/bot<TOKEN>/<path>, embedding the bot token.
|
|
The converter must not copy that URL into Image.url, or the token leaks to
|
|
the monitoring DB, dashboard and every installed plugin via the message
|
|
chain. Only base64 (which carries no token) may be stored.
|
|
"""
|
|
tg_file = MagicMock()
|
|
tg_file.file_path = TELEGRAM_FILE_URL
|
|
|
|
photo_size = MagicMock()
|
|
photo_size.get_file = AsyncMock(return_value=tg_file)
|
|
|
|
message = MagicMock()
|
|
message.text = None
|
|
message.caption = None
|
|
message.photo = [photo_size]
|
|
message.voice = None
|
|
message.document = None
|
|
|
|
response = MagicMock()
|
|
response.headers = {}
|
|
|
|
async def iter_chunked(_chunk_size):
|
|
yield b'\xff\xd8\xff\xe0jpeg-bytes'
|
|
|
|
response.content.iter_chunked = iter_chunked
|
|
|
|
@asynccontextmanager
|
|
async def fake_get(url):
|
|
yield response
|
|
|
|
fake_session = MagicMock()
|
|
fake_session.get = fake_get
|
|
|
|
with patch(
|
|
'langbot.pkg.platform.sources.telegram.httpclient.get_session',
|
|
return_value=fake_session,
|
|
):
|
|
chain = await TelegramMessageConverter.target2yiri(message, MagicMock(), 'bot-account')
|
|
|
|
images = [c for c in chain if isinstance(c, platform_message.Image)]
|
|
assert len(images) == 1
|
|
image = images[0]
|
|
|
|
# The token-bearing URL must not be retained anywhere on the component.
|
|
assert not image.url
|
|
assert image.base64 is not None
|
|
assert image.base64.startswith('data:image/jpeg;base64,')
|
|
|
|
# Belt-and-suspenders: the token must not appear in the serialized chain
|
|
# (this is what gets persisted to the monitoring DB and sent to plugins).
|
|
serialized = json.dumps(chain.model_dump(), ensure_ascii=False)
|
|
assert TELEGRAM_BOT_TOKEN not in serialized
|
|
assert 'api.telegram.org/file/bot' not in serialized
|
|
|
|
|
|
def _select_form_data() -> dict:
|
|
return {
|
|
'_current_input_field': 'choice',
|
|
'input_defs': [
|
|
{
|
|
'output_variable_name': 'choice',
|
|
'type': 'select',
|
|
'option_source': {'type': 'constant', 'value': ['A', 'B', 'C']},
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def test_telegram_select_field_options_are_extracted():
|
|
assert _telegram_select_field_options(_select_form_data()) == ('choice', ['A', 'B', 'C'])
|
|
|
|
|
|
def test_telegram_select_callback_becomes_input_progress():
|
|
assert _telegram_form_action_from_callback({'f': 1, 'x': 1}) == {
|
|
'action_id': '',
|
|
'inputs': {'select': {'index': 1}},
|
|
'_input_progress': True,
|
|
}
|
|
|
|
|
|
def test_telegram_action_callback_remains_final_action():
|
|
assert _telegram_form_action_from_callback({'f': 1, 'a': 'approve'}) == {
|
|
'action_id': 'approve',
|
|
'inputs': {},
|
|
}
|
|
|
|
|
|
def test_telegram_invalid_select_callback_is_rejected():
|
|
assert _telegram_form_action_from_callback({'f': 1, 'x': -1}) is None
|
|
assert _telegram_form_action_from_callback({'f': 1, 'x': 'invalid'}) is None
|
|
|
|
|
|
def test_telegram_form_callback_cache_consumes_the_whole_form_group():
|
|
adapter = TelegramAdapter.model_construct()
|
|
adapter._form_action_titles = {}
|
|
adapter._cache_form_action_titles({'callback-a': 'A', 'callback-b': 'B'}, now=100.0)
|
|
|
|
assert adapter._take_form_action_title('callback-a', now=101.0) == 'A'
|
|
assert adapter._take_form_action_title('callback-a', now=101.0) is None
|
|
assert adapter._take_form_action_title('callback-b', now=101.0) is None
|
|
assert adapter._form_action_titles == {}
|
|
|
|
|
|
def test_telegram_form_callback_cache_prunes_expired_entries():
|
|
adapter = TelegramAdapter.model_construct()
|
|
adapter._form_action_titles = {}
|
|
adapter._cache_form_action_titles({'callback-a': 'A'}, now=100.0)
|
|
|
|
assert adapter._take_form_action_title('callback-a', now=100.0 + adapter._FORM_ACTION_CACHE_TTL) is None
|
|
assert adapter._form_action_titles == {}
|
|
|
|
|
|
def test_telegram_form_callback_cache_preserves_pipeline_uuid():
|
|
adapter = TelegramAdapter.model_construct()
|
|
adapter._form_action_titles = {}
|
|
adapter._cache_form_action_titles(
|
|
{'callback-a': 'Approve'},
|
|
pipeline_uuid='pipeline-routed',
|
|
now=100.0,
|
|
)
|
|
|
|
assert adapter._take_form_action_context('callback-a', now=101.0) == (
|
|
'Approve',
|
|
'pipeline-routed',
|
|
)
|
|
|
|
|
|
def test_telegram_form_callback_cache_is_bounded():
|
|
adapter = TelegramAdapter.model_construct()
|
|
adapter._form_action_titles = {}
|
|
|
|
adapter._cache_form_action_titles(
|
|
{f'callback-{index}': str(index) for index in range(5000)},
|
|
now=100.0,
|
|
)
|
|
|
|
assert len(adapter._form_action_titles) == adapter._MAX_FORM_ACTION_TITLES
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_telegram_select_field_sends_two_column_inline_keyboard():
|
|
bot = MagicMock()
|
|
bot.send_message = AsyncMock()
|
|
adapter = TelegramAdapter.model_construct(bot=bot, config={}, msg_stream_id={}, seq=1, listeners={})
|
|
adapter._form_action_titles = {}
|
|
|
|
update = MagicMock()
|
|
update.effective_chat.id = 123
|
|
update.effective_message.message_thread_id = None
|
|
event = platform_events.FriendMessage(
|
|
sender=platform_entities.Friend(id='user-1', nickname='', remark=''),
|
|
message_chain=platform_message.MessageChain([]),
|
|
source_platform_object=update,
|
|
)
|
|
form_data = {
|
|
**_select_form_data(),
|
|
'node_title': 'Review',
|
|
'form_content': 'Choose one',
|
|
'workflow_run_id': 'workflow-run-12345678',
|
|
'actions': [{'id': 'approve', 'title': 'Approve'}],
|
|
}
|
|
|
|
await adapter._send_form_action_buttons(event, form_data)
|
|
|
|
args = bot.send_message.await_args.kwargs
|
|
rows = args['reply_markup'].inline_keyboard
|
|
assert [[button.text for button in row] for row in rows] == [['A', 'B'], ['C']]
|
|
callback_data = rows[0][1].callback_data
|
|
assert len(callback_data.encode('utf-8')) <= 64
|
|
assert json.loads(callback_data)['x'] == 1
|
|
assert callback_data in adapter._form_action_titles
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_telegram_text_field_does_not_show_action_buttons():
|
|
bot = MagicMock()
|
|
bot.send_message = AsyncMock()
|
|
adapter = TelegramAdapter.model_construct(bot=bot, config={}, msg_stream_id={}, seq=1, listeners={})
|
|
adapter._form_action_titles = {}
|
|
|
|
update = MagicMock()
|
|
update.effective_chat.id = 123
|
|
update.effective_message.message_thread_id = None
|
|
event = platform_events.FriendMessage(
|
|
sender=platform_entities.Friend(id='user-1', nickname='', remark=''),
|
|
message_chain=platform_message.MessageChain([]),
|
|
source_platform_object=update,
|
|
)
|
|
form_data = {
|
|
'_current_input_field': 'us_input',
|
|
'input_defs': [{'output_variable_name': 'us_input', 'type': 'paragraph'}],
|
|
'node_title': '人工介入',
|
|
'form_content': 'us_input (paragraph): reply "us_input: <value>"',
|
|
'workflow_run_id': 'workflow-run-12345678',
|
|
'actions': [{'id': 'yes', 'title': 'yes'}, {'id': 'no', 'title': 'no'}],
|
|
}
|
|
|
|
await adapter._send_form_action_buttons(event, form_data)
|
|
|
|
args = bot.send_message.await_args.kwargs
|
|
assert isinstance(args['reply_markup'], ForceReply)
|
|
assert args['reply_markup'].selective is False
|
|
assert args['reply_markup'].input_field_placeholder == 'us_input'
|
|
assert 'Please reply' not in args['text']
|
|
assert args['text'].startswith('[人工介入]')
|
|
assert 'us_input (paragraph)' in args['text']
|
|
assert adapter._form_action_titles == {}
|