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: