-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix: Telegram command security bypass and MCP result queue races #1835
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| """Regression tests for MCPToolRunner concurrent call routing.""" | ||
|
|
||
| import queue | ||
| import threading | ||
| import time | ||
| from unittest.mock import Mock, patch | ||
|
|
||
| import pytest | ||
|
|
||
|
|
||
| class TestMCPToolRunnerConcurrency: | ||
| def test_concurrent_calls_receive_matching_results(self): | ||
| from praisonaiagents.mcp.mcp import MCPToolRunner | ||
|
|
||
| with patch.object(MCPToolRunner, "start", lambda self: None): | ||
| runner = MCPToolRunner(server_params=Mock(), timeout=5) | ||
| runner.initialized.set() | ||
|
|
||
| results = {} | ||
| barrier = threading.Barrier(2) | ||
|
|
||
| def slow_worker(): | ||
| while True: | ||
| item = runner.queue.get() | ||
| if item is None: | ||
| break | ||
| response_queue, tool_name, _arguments = item | ||
| if tool_name == "slow_tool": | ||
| time.sleep(0.05) | ||
| response_queue.put((True, "slow-result")) | ||
| else: | ||
| response_queue.put((True, "fast-result")) | ||
|
|
||
| worker = threading.Thread(target=slow_worker, daemon=True) | ||
| worker.start() | ||
|
|
||
| def call_tool(name): | ||
| barrier.wait() | ||
| results[name] = runner.call_tool(name, {}) | ||
|
|
||
| threads = [ | ||
| threading.Thread(target=call_tool, args=("slow_tool",)), | ||
| threading.Thread(target=call_tool, args=("fast_tool",)), | ||
| ] | ||
| for thread in threads: | ||
| thread.start() | ||
| for thread in threads: | ||
| thread.join(timeout=5) | ||
|
|
||
| runner.queue.put(None) | ||
| worker.join(timeout=2) | ||
|
|
||
| assert results["slow_tool"] == "slow-result" | ||
| assert results["fast_tool"] == "fast-result" | ||
|
|
||
| def test_call_tool_times_out_when_worker_stalls(self): | ||
| from praisonaiagents.mcp.mcp import MCPToolRunner | ||
|
|
||
| with patch.object(MCPToolRunner, "start", lambda self: None): | ||
| runner = MCPToolRunner(server_params=Mock(), timeout=1) | ||
| runner.initialized.set() | ||
|
|
||
| result = runner.call_tool("stalled_tool", {}) | ||
| assert "timed out" in result.lower() | ||
|
|
||
| def test_init_error_is_not_returned_to_unrelated_callers(self): | ||
| from praisonaiagents.mcp.mcp import MCPToolRunner | ||
|
|
||
| with patch.object(MCPToolRunner, "start", lambda self: None): | ||
| runner = MCPToolRunner(server_params=Mock(), timeout=5) | ||
| runner.initialized.set() | ||
| runner._init_error = "MCP initialization error: boom" | ||
|
|
||
| result = runner.call_tool("any_tool", {}) | ||
| assert result == "Error: MCP initialization error: boom" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,8 +65,12 @@ def create_test_bot(allowed_users=None, allowed_channels=None, group_policy="men | |
| is_bot=True, | ||
| ) | ||
|
|
||
| # Mock the fire_message_received method | ||
| # Mock the fire_message_received method and other required attributes | ||
| bot.fire_message_received = MagicMock() | ||
| bot._started_at = 1234567890.0 | ||
| bot._agent = MagicMock() | ||
| bot._command_handlers = {} | ||
| bot._session = MagicMock() | ||
|
|
||
| return bot | ||
|
|
||
|
|
@@ -239,6 +243,85 @@ def test_security_pipeline_exists(): | |
| assert callable(process_inbound_telegram_message), "Security pipeline function should be callable" | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| @patch.object(UnknownUserHandler, 'handle') | ||
| async def test_command_handlers_respect_user_allowlist(mock_unknown_handler): | ||
| """Built-in commands must pass the same security pipeline as text messages.""" | ||
| mock_unknown_handler.return_value = False | ||
|
|
||
| bot = create_test_bot(allowed_users=["42"]) | ||
|
|
||
| # Mock the reply_text method to track if command handlers were called | ||
| reply_mock = AsyncMock() | ||
|
|
||
| # Test that disallowed users are blocked by command handlers | ||
| for command in ("help", "status", "new"): | ||
| update = create_mock_telegram_update( | ||
| user_id="99", | ||
| text=f"/{command}", | ||
| chat_type="private", | ||
| ) | ||
| update.message.reply_text = reply_mock | ||
| reply_mock.reset_mock() | ||
|
|
||
| # Get the registered handler for this command from the bot's handlers | ||
| # We need to simulate how the telegram bot framework would call the handler | ||
| if command == "help": | ||
| from praisonai.bots.telegram import TelegramBot | ||
| # Create a handler like the bot does | ||
| async def test_handle_help(update, context): | ||
| if not update.message: | ||
| return | ||
| if not await process_inbound_telegram_message(update, bot): | ||
| return | ||
| await update.message.reply_text(bot._format_help()) | ||
| await test_handle_help(update, None) | ||
| elif command == "status": | ||
| async def test_handle_status(update, context): | ||
| if not update.message: | ||
| return | ||
| if not await process_inbound_telegram_message(update, bot): | ||
| return | ||
| await update.message.reply_text(bot._format_status()) | ||
| await test_handle_status(update, None) | ||
| elif command == "new": | ||
| async def test_handle_new(update, context): | ||
| if not update.message: | ||
| return | ||
| message = await process_inbound_telegram_message(update, bot) | ||
| if not message: | ||
| return | ||
| user_id = message.sender.user_id if message.sender else "unknown" | ||
| bot._session.reset(user_id) | ||
| await update.message.reply_text("Session reset. Starting fresh conversation.") | ||
| # Mock session reset | ||
| bot._session = MagicMock() | ||
| bot._session.reset = MagicMock() | ||
| await test_handle_new(update, None) | ||
|
|
||
| # Assert the command handler did not reply (because security blocked it) | ||
| reply_mock.assert_not_called(), f"/{command} from disallowed user should not reply" | ||
|
|
||
| # Test that allowed users can use commands | ||
| allowed_update = create_mock_telegram_update( | ||
| user_id="42", | ||
| text="/help", | ||
| chat_type="private", | ||
| ) | ||
| allowed_update.message.reply_text = reply_mock | ||
| reply_mock.reset_mock() | ||
|
|
||
| async def test_handle_help_allowed(update, context): | ||
| if not update.message: | ||
| return | ||
| if not await process_inbound_telegram_message(update, bot): | ||
| return | ||
| await update.message.reply_text(bot._format_help()) | ||
|
|
||
| await test_handle_help_allowed(allowed_update, None) | ||
| reply_mock.assert_called_once(), "Commands from allowed users should reply" | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_shared_pipeline_consistency(): | ||
| """Test that the shared pipeline provides consistent results.""" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
process_inbound_telegram_messageEach of the three command handlers (
handle_status,handle_new,handle_help) independently issuesfrom praisonai.bots.telegram import process_inbound_telegram_messageat call-time. Python module caching makes this functionally correct but it is redundant and inconsistent with the rest of the file's import style. A single import at the outer function scope (or at module level alongside the otherpraisonai.botsimports) would be cleaner.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!