diff --git a/skills/polymarket-browse/scripts/browse.py b/skills/polymarket-browse/scripts/browse.py index 8ce83e0..33b56ec 100644 --- a/skills/polymarket-browse/scripts/browse.py +++ b/skills/polymarket-browse/scripts/browse.py @@ -4,6 +4,7 @@ Polymarket Event Browser Browse tradeable Polymarket events by game category. """ +import html import json import time import argparse @@ -577,6 +578,15 @@ def print_detail(e, detail): # TELEGRAM # ============================================================ +def escape_html(text): + """Escape HTML-sensitive characters for Telegram parse_mode=HTML.""" + return (text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """)) + + def send_telegram_message(bot_token, chat_id, text, timeout=10): """Send a message via Telegram bot API. Returns the message ID on success. @@ -645,7 +655,7 @@ def send_to_telegram(match_events, non_match_events, category, matches_only=Fals odds_b = format_odds(float(prices[1])) if len(prices) > 1 else "?" tournament = get_tournament(title) title_clean = title.split(" - ")[0].strip() if " - " in title else title - lines.append(f"{i}. {title_clean}") + lines.append(f"{i}. {escape_html(title_clean)}") lines.append(f" {start_time_wib} | {rel_time}") lines.append(f" Vol: ${vol:,.0f}") if tournament: @@ -666,7 +676,7 @@ def send_to_telegram(match_events, non_match_events, category, matches_only=Fals start_time_wib, rel_time = get_start_time_wib(e) total_vol = sum(float(m.get("volume", 0)) for m in e.get("markets", [])) market_count = len(e.get("markets", [])) - lines.append(f"{i}. {title}") + lines.append(f"{i}. {escape_html(title)}") lines.append(f" {start_time_wib} | {rel_time}") lines.append(f" Markets: {market_count} | Total Vol: ${total_vol:,.0f}") lines.append("") diff --git a/skills/polymarket-browse/tests/test_browse.py b/skills/polymarket-browse/tests/test_browse.py index 7b5b8a5..3398dcf 100644 --- a/skills/polymarket-browse/tests/test_browse.py +++ b/skills/polymarket-browse/tests/test_browse.py @@ -121,5 +121,85 @@ class TestSendTelegramMessage(unittest.TestCase): self.assertIn(b"parse_mode=HTML", req.data) +class TestHtmlInjection(unittest.TestCase): + """Tests for HTML injection prevention in Telegram messages.""" + + @patch.dict('os.environ', {'TELEGRAM_BOT_TOKEN': 'test_token', 'CHAT_ID': 'test_chat'}) + @patch('browse.send_telegram_message') + def test_send_to_telegram_html_injection_in_match_title(self, mock_send_msg): + """ + titles in match events are NOT escaped before inserting into HTML. + This test FAILS if HTML chars are unescaped (vulnerable), + and PASSES once escape_html() is implemented. + """ + mock_send_msg.return_value = 123 + + # Simulate a Polymarket event with HTML injection in the title + malicious_event = { + "title": " - Team A vs Team B", + "slug": "test-event", + "startTime": "2027-03-26T12:00:00Z", + "markets": [{ + "sportsMarketType": "moneyline", + "outcomes": '["Team A", "Team B"]', + "outcomePrices": "[0.55, 0.45]", + "bestBid": "0.54", + "bestAsk": "0.56", + "volume": 50000, + "acceptingOrders": True, + "closed": False, + }], + } + + from browse import send_to_telegram + send_to_telegram([malicious_event], [], "Counter Strike") + + # Check what was passed to send_telegram_message + self.assertEqual(mock_send_msg.called, True) + sent_text = mock_send_msg.call_args[0][2] # text arg (3rd positional) + + # AFTER FIX: