SilverElixir commited on
Commit
7127800
·
1 Parent(s): 0783f54

Security: validate pick before pop (foreign tap no longer burns token), SSRF guard on download hosts

Browse files
lumen_commands.py CHANGED
@@ -578,16 +578,20 @@ async def handle_pick_callback(query: CallbackQuery) -> None:
578
  with contextlib.suppress(Exception):
579
  await query.answer()
580
  return
581
- # Pop сразу: повторный тап не плодит второй ответ (чужая кнопка "сгорает" — владелец переспросит текстом).
582
- rec = bot._pending_picks.pop(token, None)
 
 
583
  # Язык для служебных реплик: из записи (если есть), иначе из чата кнопки.
584
  _qchat = query.message.chat.id if query.message and query.message.chat else None
585
  rec_lang = (rec or {}).get("lang") or bot._chat_lang(_qchat)
586
  if rec is None or rec["expires"] < time.monotonic():
587
  if rec is not None and query.message is not None:
588
  # Протухший, но известный выбор — молча выдаём свежие кнопки вместо
589
- # стены "протухло, пиши текстом". Токен уже popped: второй такой тап
590
- # упрётся в rec None ниже и честно покажет pick_expired. Один ресенд.
 
 
591
  _purge_expired_picks()
592
  _enforce_pending_picks_cap()
593
  fresh = secrets.token_hex(4)
@@ -625,6 +629,8 @@ async def handle_pick_callback(query: CallbackQuery) -> None:
625
  with contextlib.suppress(Exception):
626
  await query.answer(_lang_t(rec_lang, "pick_not_yours"), show_alert=True)
627
  return
 
 
628
  choice = options[idx]
629
  with contextlib.suppress(Exception):
630
  await query.answer()
 
578
  with contextlib.suppress(Exception):
579
  await query.answer()
580
  return
581
+ # Сначала все проверки по записи, удаление — только перед делом: чужой тап
582
+ # или кривой индекс больше не сжигают кнопку владельца. Атомарность та же,
583
+ # что у прежнего pop-first: всё синхронно до первого await ниже.
584
+ rec = bot._pending_picks.get(token)
585
  # Язык для служебных реплик: из записи (если есть), иначе из чата кнопки.
586
  _qchat = query.message.chat.id if query.message and query.message.chat else None
587
  rec_lang = (rec or {}).get("lang") or bot._chat_lang(_qchat)
588
  if rec is None or rec["expires"] < time.monotonic():
589
  if rec is not None and query.message is not None:
590
  # Протухший, но известный выбор — молча выдаём свежие кнопки вместо
591
+ # стены "протухло, пиши текстом". Старый токен забираем явно (выше
592
+ # теперь get, а не pop). Второй такой тап упрётся в rec None ниже
593
+ # и честно покажет pick_expired. Один ресенд.
594
+ bot._pending_picks.pop(token, None)
595
  _purge_expired_picks()
596
  _enforce_pending_picks_cap()
597
  fresh = secrets.token_hex(4)
 
629
  with contextlib.suppress(Exception):
630
  await query.answer(_lang_t(rec_lang, "pick_not_yours"), show_alert=True)
631
  return
632
+ # Все проверки пройдены — только теперь забираем токен (см. комментарий у get выше).
633
+ bot._pending_picks.pop(token, None)
634
  choice = options[idx]
635
  with contextlib.suppress(Exception):
636
  await query.answer()
lumen_tiktok.py CHANGED
@@ -8,9 +8,11 @@ from __future__ import annotations
8
 
9
  import asyncio
10
  import contextlib
 
11
  import logging
12
  import os
13
  import re
 
14
  import tempfile
15
  import time
16
  import urllib.parse
@@ -250,12 +252,46 @@ def _write_mp3_tags(path: str, title: str, artist: str, cover: bytes | None) ->
250
  TIKTOK_DOWNLOAD_MAX_BYTES = int(os.getenv("TIKTOK_DOWNLOAD_MAX_BYTES", str(75 * 1024 * 1024)))
251
 
252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: dict | None = None) -> bytes | None:
254
- # URL — из JSON чужого сервиса (TikWM): качаем только http(s) (AUD-D-003).
 
255
  scheme = urllib.parse.urlsplit(url).scheme.lower()
256
  if scheme not in ("http", "https"):
257
  log.warning("[download] Refusing non-HTTP(S) URL (scheme=%r).", scheme)
258
  return None
 
 
 
259
  if headers is None:
260
  headers = {
261
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
@@ -266,6 +302,13 @@ async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: d
266
  async with session.get(url, headers=headers, timeout=60) as resp:
267
  if resp.status != 200:
268
  return None
 
 
 
 
 
 
 
269
  # Content-Length — быстрый отказ ДО скачивания (сервер может соврать/не прислать — ниже та же проверка потоково по факту).
270
  content_length = resp.headers.get("Content-Length")
271
  if content_length is not None:
 
8
 
9
  import asyncio
10
  import contextlib
11
+ import ipaddress
12
  import logging
13
  import os
14
  import re
15
+ import socket
16
  import tempfile
17
  import time
18
  import urllib.parse
 
252
  TIKTOK_DOWNLOAD_MAX_BYTES = int(os.getenv("TIKTOK_DOWNLOAD_MAX_BYTES", str(75 * 1024 * 1024)))
253
 
254
 
255
+ # ── SSRF-гард для _download_url_bin ──
256
+ # Скомпрометированная выдача TikWM могла бы подсунуть внутренний адрес
257
+ # (localhost, metadata-IP облака 169.254.169.254 и т.п.) — сервер сам сходил бы
258
+ # внутрь своей сети. Литералы проверяем без DNS, хосты — через резолв
259
+ # (fail-closed: не резолвится — не качаем). Известное ограничение: TOCTOU между
260
+ # резолвом и коннектом (DNS-rebinding) лечится только на уровне коннектора;
261
+ # для нашей угрозы (прямой внутренний адрес в JSON) предпроверки достаточно.
262
+ def _host_resolves_to_public(host: str | None) -> bool:
263
+ if not host:
264
+ return False
265
+ host = host.strip().strip("[]").lower()
266
+ if host == "localhost":
267
+ return False
268
+ try:
269
+ return ipaddress.ip_address(host).is_global
270
+ except ValueError:
271
+ pass
272
+ try:
273
+ infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
274
+ except (socket.gaierror, UnicodeError):
275
+ return False
276
+ addrs = {info[4][0] for info in infos}
277
+ if not addrs:
278
+ return False
279
+ try:
280
+ return all(ipaddress.ip_address(a).is_global for a in addrs)
281
+ except ValueError:
282
+ return False
283
+
284
+
285
  async def _download_url_bin(session: aiohttp.ClientSession, url: str, headers: dict | None = None) -> bytes | None:
286
+ # URL — из JSON чужого сервиса (TikWM): качаем только http(s) (AUD-D-003)
287
+ # и только с публичных адресов (SSRF-гард ниже).
288
  scheme = urllib.parse.urlsplit(url).scheme.lower()
289
  if scheme not in ("http", "https"):
290
  log.warning("[download] Refusing non-HTTP(S) URL (scheme=%r).", scheme)
291
  return None
292
+ if not _host_resolves_to_public(urllib.parse.urlsplit(url).hostname):
293
+ log.warning("[download] Refusing non-public host for URL %r.", url)
294
+ return None
295
  if headers is None:
296
  headers = {
297
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
 
302
  async with session.get(url, headers=headers, timeout=60) as resp:
303
  if resp.status != 200:
304
  return None
305
+ # Редирект мог увести на внутренний адрес уже после предпроверки —
306
+ # сверяем конечный хост тоже. У тестовых заглушек .url нет — их пропускаем.
307
+ final_url = getattr(resp, "url", None)
308
+ final_host = urllib.parse.urlsplit(str(final_url)).hostname if final_url is not None else None
309
+ if final_host is not None and not _host_resolves_to_public(final_host):
310
+ log.warning("[download] Refusing redirect to non-public host %r.", final_host)
311
+ return None
312
  # Content-Length — быстрый отказ ДО скачивания (сервер может соврать/не прислать — ниже та же проверка потоково по факту).
313
  content_length = resp.headers.get("Content-Length")
314
  if content_length is not None:
tests/test_bot_commands.py CHANGED
@@ -694,6 +694,31 @@ def test_pick_callback_wrong_user_rejected(monkeypatch):
694
  bot._pending_picks.clear()
695
 
696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  def test_pick_callback_bad_data_answered_quietly():
698
  for data in ("pick:", "pick:tok:notanint"):
699
  q = _make_pick_query(data)
 
694
  bot._pending_picks.clear()
695
 
696
 
697
+ def test_pick_callback_foreign_tap_does_not_burn_token(monkeypatch):
698
+ # Регрессия: pop до проверки владельца сжигал кнопку — чужак одним тапом
699
+ # лишал владельца выбора. Теперь чужой тап отклоняется, свой после — работает.
700
+ bot._pending_picks.clear()
701
+ bot._pending_picks["dd44ee55"] = {
702
+ "chat_id": 777, "user_id": 111, "scenario": "film",
703
+ "original": "посоветуй фильм", "expires": time.monotonic() + 300,
704
+ "lang": "ru",
705
+ }
706
+ fake_core = AsyncMock()
707
+ monkeypatch.setattr(bot, "_handle_message_core", fake_core)
708
+ try:
709
+ q_foreign = _make_pick_query("pick:dd44ee55:0", user_id=999)
710
+ asyncio.run(bot.handle_pick_callback(q_foreign))
711
+ assert any(alert is True for _, alert in q_foreign.answered)
712
+ assert "dd44ee55" in bot._pending_picks
713
+ q_owner = _make_pick_query("pick:dd44ee55:2")
714
+ asyncio.run(bot.handle_pick_callback(q_owner))
715
+ assert "Триллер" in q_owner.message.edits[0][0]
716
+ fake_core.assert_awaited_once()
717
+ assert "dd44ee55" not in bot._pending_picks
718
+ finally:
719
+ bot._pending_picks.clear()
720
+
721
+
722
  def test_pick_callback_bad_data_answered_quietly():
723
  for data in ("pick:", "pick:tok:notanint"):
724
  q = _make_pick_query(data)
tests/test_bot_tiktok.py CHANGED
@@ -1055,6 +1055,35 @@ def test_download_url_bin_refuses_non_http_scheme_without_fetching():
1055
  assert asyncio.run(lumen_tiktok._download_url_bin(session, "ftp://evil.example/x.mp4")) is None
1056
 
1057
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1058
  def test_download_url_bin_returns_none_on_non_200_status():
1059
  resp = _FakeDownloadResponse([b"error page"], status=404)
1060
  session = _FakeDownloadSession(resp)
 
1055
  assert asyncio.run(lumen_tiktok._download_url_bin(session, "ftp://evil.example/x.mp4")) is None
1056
 
1057
 
1058
+ def test_host_resolves_to_public_blocks_internal_addresses():
1059
+ # SSRF-гард: литералы проверяются без DNS, всё непубличное режем.
1060
+ assert lumen_tiktok._host_resolves_to_public("8.8.8.8") is True
1061
+ assert lumen_tiktok._host_resolves_to_public("127.0.0.1") is False
1062
+ assert lumen_tiktok._host_resolves_to_public("10.0.0.5") is False
1063
+ assert lumen_tiktok._host_resolves_to_public("192.168.1.20") is False
1064
+ assert lumen_tiktok._host_resolves_to_public("169.254.169.254") is False
1065
+ assert lumen_tiktok._host_resolves_to_public("::1") is False
1066
+ assert lumen_tiktok._host_resolves_to_public("localhost") is False
1067
+ assert lumen_tiktok._host_resolves_to_public("") is False
1068
+ assert lumen_tiktok._host_resolves_to_public(None) is False
1069
+
1070
+
1071
+ def test_download_url_bin_refuses_internal_ips_without_fetching():
1072
+ # Тот же гард сквозно: до session.get дело не доходит вообще.
1073
+ class _ExplodingSession:
1074
+ def get(self, *args, **kwargs):
1075
+ raise AssertionError("no fetch must happen for internal IPs")
1076
+
1077
+ session = _ExplodingSession()
1078
+ for url in (
1079
+ "http://127.0.0.1/evil.mp4",
1080
+ "http://10.1.2.3/evil.mp4",
1081
+ "http://169.254.169.254/latest/meta-data/",
1082
+ "http://[::1]/evil.mp4",
1083
+ ):
1084
+ assert asyncio.run(lumen_tiktok._download_url_bin(session, url)) is None
1085
+
1086
+
1087
  def test_download_url_bin_returns_none_on_non_200_status():
1088
  resp = _FakeDownloadResponse([b"error page"], status=404)
1089
  session = _FakeDownloadSession(resp)