Spaces:
Running
Running
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 +10 -4
- lumen_tiktok.py +44 -1
- tests/test_bot_commands.py +25 -0
- tests/test_bot_tiktok.py +29 -0
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 |
-
#
|
| 582 |
-
|
|
|
|
|
|
|
| 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 |
-
# стены "протухло, пиши текстом".
|
| 590 |
-
#
|
|
|
|
|
|
|
| 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)
|