SilverElixir commited on
Commit
2dcccd0
·
1 Parent(s): 168044d

Harden TikTok downloads with caps and URL checks

Browse files
lumen_tiktok.py CHANGED
@@ -121,11 +121,19 @@ def _tiktok_video_candidates(media_data: dict) -> list[dict[str, Any]]:
121
  raw_url = media_data.get(url_key)
122
  if not raw_url:
123
  continue
 
 
 
124
  if raw_url.startswith("//"):
125
  # Протокол-относительный URL чиним как https, иначе ниже склеилось бы в битый "https://www.tikwm.com//host/path".
126
  raw_url = "https:" + raw_url
127
  elif not raw_url.startswith("http"):
128
- raw_url = "https://www.tikwm.com" + raw_url
 
 
 
 
 
129
  try:
130
  size_bytes = int(media_data.get(size_key) or 0)
131
  except (TypeError, ValueError):
@@ -157,14 +165,19 @@ async def _tikwm_throttle() -> None:
157
  _tikwm_last_request_ts = now
158
 
159
 
160
- async def _read_capped_body(resp: aiohttp.ClientResponse, url: str) -> bytes | None:
 
 
 
 
161
  # JSON TikWM обязан быть маленьким: читаем с тем же капом, что бинарные
162
  # скачивания, иначе скомпрометированный прокси льёт в память без края.
 
163
  content_length = resp.headers.get("Content-Length")
164
  if content_length is not None:
165
  try:
166
- if int(content_length) > TIKTOK_DOWNLOAD_MAX_BYTES:
167
- log.warning("[tikwm] Refusing %s: Content-Length %s exceeds the %d byte cap.", url, content_length, TIKTOK_DOWNLOAD_MAX_BYTES)
168
  return None
169
  except ValueError:
170
  pass
@@ -172,8 +185,8 @@ async def _read_capped_body(resp: aiohttp.ClientResponse, url: str) -> bytes | N
172
  total = 0
173
  async for chunk in resp.content.iter_chunked(65536):
174
  total += len(chunk)
175
- if total > TIKTOK_DOWNLOAD_MAX_BYTES:
176
- log.warning("[tikwm] Aborting %s: exceeded the %d byte cap mid-stream.", url, TIKTOK_DOWNLOAD_MAX_BYTES)
177
  return None
178
  chunks.append(chunk)
179
  return b"".join(chunks)
@@ -211,7 +224,7 @@ async def _fetch_tikwm_media_data(
211
  async with session.get(api_url, timeout=12, headers=api_headers) as r:
212
  if r.status == 200:
213
  all_attempts_403_empty = False
214
- raw_body = await _read_capped_body(r, api_url)
215
  if raw_body is None:
216
  continue
217
  try:
@@ -224,7 +237,8 @@ async def _fetch_tikwm_media_data(
224
  return res.get("data")
225
  log.warning("[tikwm] Endpoint %s returned code %s: %s", api_url, res.get("code"), res.get("msg"))
226
  else:
227
- body = await r.read()
 
228
  if r.status == 403:
229
  saw_403 = True
230
  if r.status != 403 or body:
 
121
  raw_url = media_data.get(url_key)
122
  if not raw_url:
123
  continue
124
+ if not isinstance(raw_url, str):
125
+ log.warning("[tiktok] Skipping non-string URL for %s: %r.", url_key, raw_url)
126
+ continue
127
  if raw_url.startswith("//"):
128
  # Протокол-относительный URL чиним как https, иначе ниже склеилось бы в битый "https://www.tikwm.com//host/path".
129
  raw_url = "https:" + raw_url
130
  elif not raw_url.startswith("http"):
131
+ # Склейка давала userinfo-путаницу ("@evil.com/x" ехал хостом):
132
+ # только относительный путь к базовому хосту.
133
+ raw_url = urllib.parse.urljoin("https://www.tikwm.com/", raw_url)
134
+ if urllib.parse.urlsplit(raw_url).scheme.lower() not in ("http", "https"):
135
+ log.warning("[tiktok] Skipping non-HTTP(S) URL for %s: %r.", url_key, raw_url)
136
+ continue
137
  try:
138
  size_bytes = int(media_data.get(size_key) or 0)
139
  except (TypeError, ValueError):
 
165
  _tikwm_last_request_ts = now
166
 
167
 
168
+ # JSON TikWM обязан быть маленьким: свой кап, а не общий видео-лимит.
169
+ TIKWM_JSON_MAX_BYTES = 1 * 1024 * 1024
170
+
171
+
172
+ async def _read_capped_body(resp: aiohttp.ClientResponse, url: str, *, cap_bytes: int | None = None) -> bytes | None:
173
  # JSON TikWM обязан быть маленьким: читаем с тем же капом, что бинарные
174
  # скачивания, иначе скомпрометированный прокси льёт в память без края.
175
+ cap = cap_bytes if cap_bytes is not None else TIKTOK_DOWNLOAD_MAX_BYTES
176
  content_length = resp.headers.get("Content-Length")
177
  if content_length is not None:
178
  try:
179
+ if int(content_length) > cap:
180
+ log.warning("[tikwm] Refusing %s: Content-Length %s exceeds the %d byte cap.", url, content_length, cap)
181
  return None
182
  except ValueError:
183
  pass
 
185
  total = 0
186
  async for chunk in resp.content.iter_chunked(65536):
187
  total += len(chunk)
188
+ if total > cap:
189
+ log.warning("[tikwm] Aborting %s: exceeded the %d byte cap mid-stream.", url, cap)
190
  return None
191
  chunks.append(chunk)
192
  return b"".join(chunks)
 
224
  async with session.get(api_url, timeout=12, headers=api_headers) as r:
225
  if r.status == 200:
226
  all_attempts_403_empty = False
227
+ raw_body = await _read_capped_body(r, api_url, cap_bytes=TIKWM_JSON_MAX_BYTES)
228
  if raw_body is None:
229
  continue
230
  try:
 
237
  return res.get("data")
238
  log.warning("[tikwm] Endpoint %s returned code %s: %s", api_url, res.get("code"), res.get("msg"))
239
  else:
240
+ raw_error = await _read_capped_body(r, api_url, cap_bytes=TIKWM_JSON_MAX_BYTES)
241
+ body = raw_error if raw_error is not None else b""
242
  if r.status == 403:
243
  saw_403 = True
244
  if r.status != 403 or body:
lumen_tiktok_flow.py CHANGED
@@ -20,6 +20,7 @@ from aiogram.types import (
20
  )
21
 
22
  from lumen_tiktok import (
 
23
  _original_sound_label,
24
  _GENERIC_ORIGINAL_SOUND_PHRASES,
25
  _chunk_tiktok_media_items,
@@ -210,7 +211,12 @@ async def _fetch_tikwm_media_data_with_proxy_fallback(session: aiohttp.ClientSes
210
  import bot
211
  result = None
212
  for candidate in bot._tikwm_proxy_candidates():
213
- result = await bot._fetch_tikwm_media_data(session, resolved_url, headers, proxy_base_url=candidate)
 
 
 
 
 
214
  if result is not None:
215
  return result
216
  return result
@@ -240,7 +246,11 @@ async def _try_send_tiktok_slideshow(
240
  async with bot._tiktok_slide_download_semaphore:
241
  if post_budget[0] <= 0:
242
  return None
243
- data = await bot._download_url_bin(session, slide_url, headers=headers)
 
 
 
 
244
  if data:
245
  post_budget[0] -= len(data)
246
  if post_budget[0] < 0:
 
20
  )
21
 
22
  from lumen_tiktok import (
23
+ TIKTOK_DOWNLOAD_MAX_BYTES,
24
  _original_sound_label,
25
  _GENERIC_ORIGINAL_SOUND_PHRASES,
26
  _chunk_tiktok_media_items,
 
211
  import bot
212
  result = None
213
  for candidate in bot._tikwm_proxy_candidates():
214
+ try:
215
+ result = await bot._fetch_tikwm_media_data(session, resolved_url, headers, proxy_base_url=candidate)
216
+ except Exception as exc:
217
+ # Упавший кандидат не отменяет остальных: пробуем дальше.
218
+ log.warning("[tiktok] TikWM proxy candidate %r failed: %s", candidate, exc)
219
+ continue
220
  if result is not None:
221
  return result
222
  return result
 
246
  async with bot._tiktok_slide_download_semaphore:
247
  if post_budget[0] <= 0:
248
  return None
249
+ # Резерв до скачивания: кап слайда — остаток бюджета.
250
+ data = await bot._download_url_bin(
251
+ session, slide_url, headers=headers,
252
+ cap_bytes=min(TIKTOK_DOWNLOAD_MAX_BYTES, post_budget[0]),
253
+ )
254
  if data:
255
  post_budget[0] -= len(data)
256
  if post_budget[0] < 0:
tests/bot_test_helpers.py CHANGED
@@ -345,16 +345,15 @@ class _FakeTikTokSession:
345
 
346
 
347
  class _FakeTikwmApiResponse:
348
- def __init__(self, status=200, json_body=None, body_bytes=b""):
349
  import json as _json
350
  self.status = status
351
  self._json_body = json_body or {}
352
- self._body_bytes = body_bytes
353
- # Честный фейк (см. _FakeTikTokResponse): контент из json, если байты не заданы.
 
354
  self.headers = {}
355
- self.content = _FakeDownloadContent(
356
- [body_bytes] if body_bytes else [_json.dumps(self._json_body).encode("utf-8")]
357
- )
358
 
359
  async def __aenter__(self):
360
  return self
 
345
 
346
 
347
  class _FakeTikwmApiResponse:
348
+ def __init__(self, status=200, json_body=None, body_bytes: bytes | None = None):
349
  import json as _json
350
  self.status = status
351
  self._json_body = json_body or {}
352
+ # Явно заданные байты (включая пустые) едут и в read(), и в стрим,
353
+ # как у настоящего ответа.
354
+ self._body_bytes = body_bytes if body_bytes is not None else _json.dumps(self._json_body).encode("utf-8")
355
  self.headers = {}
356
+ self.content = _FakeDownloadContent([self._body_bytes])
 
 
357
 
358
  async def __aenter__(self):
359
  return self
tests/test_bot_tiktok.py CHANGED
@@ -1667,6 +1667,71 @@ def test_fetch_tikwm_media_data_skips_huge_content_length():
1667
  assert result is None
1668
 
1669
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1670
  def test_probe_video_dimensions_survives_na_width(monkeypatch):
1671
  # A7-6: width=N/A раньше ронял float() и вместе с ним валидный duration.
1672
  class _FakeFFprobe:
@@ -1726,6 +1791,17 @@ def test_tiktok_video_candidates_protocol_relative_url():
1726
  assert candidates[0]["url"] == "https://cdn.tikwm.com/v.mp4"
1727
 
1728
 
 
 
 
 
 
 
 
 
 
 
 
1729
  def test_download_url_bin_honors_caller_cap():
1730
  # Аудит A7-5: видео режем лимитом отправки (50МБ), а не общим 75МБ.
1731
  mapping = {"http://8.8.8.8/v.mp4": _FakeDownloadResponse([b"123456789012345"])}
@@ -1739,7 +1815,7 @@ def test_slideshow_stops_at_post_budget(monkeypatch):
1739
  # Аудит D1/A7-1: общий кап RAM на пост — остаток слайдов пропускается.
1740
  calls = {}
1741
 
1742
- async def fake_download(session, url, headers=None):
1743
  return b"k" * 1024
1744
 
1745
  class _FakeTgBot:
@@ -1776,3 +1852,45 @@ def test_slideshow_stops_at_post_budget(monkeypatch):
1776
  finally:
1777
  bot.chat_state.pop(999985, None)
1778
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1667
  assert result is None
1668
 
1669
 
1670
+ def test_fetch_tikwm_media_data_json_capped_below_video_limit():
1671
+ # JSON капнут своим мегабайтом, а не общим видео-лимитом 75МБ.
1672
+ import lumen_tiktok
1673
+ from tests.bot_test_helpers import _FakeTikwmApiResponse, _FakeTikwmApiSession
1674
+ assert lumen_tiktok.TIKWM_JSON_MAX_BYTES <= 2 * 1024 * 1024
1675
+ big = b'{"code": 0, "data": {}, "pad": "' + b"x" * (lumen_tiktok.TIKWM_JSON_MAX_BYTES + 100) + b'"}'
1676
+ resp = _FakeTikwmApiResponse(status=200, body_bytes=big)
1677
+ result = asyncio.run(bot._fetch_tikwm_media_data(
1678
+ _FakeTikwmApiSession([resp]), "https://www.tiktok.com/@u/video/1", {}))
1679
+ assert result is None
1680
+
1681
+
1682
+ def test_fetch_tikwm_media_data_error_branch_reads_capped():
1683
+ # Ветка ошибки тоже читает с капом, а не голым read().
1684
+ from tests.bot_test_helpers import _FakeDownloadContent, _FakeTikwmApiSession
1685
+ calls = []
1686
+
1687
+ class _NoUncappedReadResponse:
1688
+ status = 500
1689
+ headers = {"Content-Length": str(10 ** 12)}
1690
+
1691
+ def __init__(self):
1692
+ self.content = _FakeDownloadContent([b"e" * 100])
1693
+
1694
+ async def __aenter__(self):
1695
+ return self
1696
+
1697
+ async def __aexit__(self, *args):
1698
+ return False
1699
+
1700
+ async def read(self):
1701
+ calls.append("read")
1702
+ return b"e" * 100
1703
+
1704
+ result = asyncio.run(bot._fetch_tikwm_media_data(
1705
+ _FakeTikwmApiSession([_NoUncappedReadResponse()]), "https://www.tiktok.com/@u/video/1", {}))
1706
+ assert result is None
1707
+ assert calls == []
1708
+
1709
+
1710
+ def test_tikwm_proxy_fallback_tries_next_candidate_on_exception(monkeypatch):
1711
+ # Исключение первого кандидата не отменяет остальных.
1712
+ import lumen_tiktok_flow
1713
+ calls = []
1714
+
1715
+ async def flaky_fetch(session, resolved_url, headers, proxy_base_url=""):
1716
+ calls.append(proxy_base_url)
1717
+ if len(calls) == 1:
1718
+ raise RuntimeError("proxy down")
1719
+ return {"play": "https://tikwm.com/v.mp4"}
1720
+
1721
+ async def always_boom(session, resolved_url, headers, proxy_base_url=""):
1722
+ raise RuntimeError("all down")
1723
+
1724
+ monkeypatch.setattr(bot, "_tikwm_proxy_candidates", lambda: ["http://p1", "http://p2"])
1725
+ monkeypatch.setattr(bot, "_fetch_tikwm_media_data", flaky_fetch)
1726
+ result = asyncio.run(lumen_tiktok_flow._fetch_tikwm_media_data_with_proxy_fallback(
1727
+ None, "https://www.tiktok.com/@u/video/1", {}))
1728
+ assert result == {"play": "https://tikwm.com/v.mp4"}
1729
+ assert calls == ["http://p1", "http://p2"]
1730
+ monkeypatch.setattr(bot, "_fetch_tikwm_media_data", always_boom)
1731
+ assert asyncio.run(lumen_tiktok_flow._fetch_tikwm_media_data_with_proxy_fallback(
1732
+ None, "https://www.tiktok.com/@u/video/1", {})) is None
1733
+
1734
+
1735
  def test_probe_video_dimensions_survives_na_width(monkeypatch):
1736
  # A7-6: width=N/A раньше ронял float() и вместе с ним валидный duration.
1737
  class _FakeFFprobe:
 
1791
  assert candidates[0]["url"] == "https://cdn.tikwm.com/v.mp4"
1792
 
1793
 
1794
+ def test_tiktok_video_candidates_rejects_userinfo_and_non_string():
1795
+ # Склейка давала userinfo-путаницу: "@evil.com/x" ехал хостом.
1796
+ import urllib.parse
1797
+ candidates = bot._tiktok_video_candidates({"play": "@evil.com/x", "size": 1})
1798
+ assert candidates[0]["url"] == "https://www.tikwm.com/@evil.com/x"
1799
+ assert urllib.parse.urlsplit(candidates[0]["url"]).hostname == "www.tikwm.com"
1800
+ assert bot._tiktok_video_candidates({"play": "javascript:alert(1)", "size": 1}) == []
1801
+ assert bot._tiktok_video_candidates({"play": 12345, "size": 1}) == []
1802
+ assert bot._tiktok_video_candidates({"play": {"u": 1}, "size": 1}) == []
1803
+
1804
+
1805
  def test_download_url_bin_honors_caller_cap():
1806
  # Аудит A7-5: видео режем лимитом отправки (50МБ), а не общим 75МБ.
1807
  mapping = {"http://8.8.8.8/v.mp4": _FakeDownloadResponse([b"123456789012345"])}
 
1815
  # Аудит D1/A7-1: общий кап RAM на пост — остаток слайдов пропускается.
1816
  calls = {}
1817
 
1818
+ async def fake_download(session, url, headers=None, cap_bytes=None):
1819
  return b"k" * 1024
1820
 
1821
  class _FakeTgBot:
 
1852
  finally:
1853
  bot.chat_state.pop(999985, None)
1854
 
1855
+
1856
+ class _SlideshowCapBot:
1857
+ async def send_photo(self, **kwargs):
1858
+ return SimpleNamespace()
1859
+
1860
+ async def send_video(self, **kwargs):
1861
+ return SimpleNamespace()
1862
+
1863
+ async def send_media_group(self, **kwargs):
1864
+ return SimpleNamespace()
1865
+
1866
+
1867
+ def test_slideshow_slide_download_reserves_post_budget(monkeypatch):
1868
+ # Кап слайда — остаток общего бюджета до скачивания, а не после.
1869
+ import lumen_tiktok
1870
+ seen_caps = {}
1871
+
1872
+ async def fake_download(session, url, headers=None, cap_bytes=None):
1873
+ seen_caps[url] = cap_bytes
1874
+ return b"k" * 1024
1875
+
1876
+ async def fake_edit(*args, **kwargs):
1877
+ return True
1878
+
1879
+ monkeypatch.setattr(bot, "TIKTOK_SLIDESHOW_MAX_BYTES", 1500)
1880
+ monkeypatch.setattr(bot, "_download_url_bin", fake_download)
1881
+ monkeypatch.setattr(bot, "_edit_message_quietly", fake_edit)
1882
+ monkeypatch.setattr(bot, "_delete_message_quietly", fake_edit)
1883
+ monkeypatch.setattr(bot, "bot", _SlideshowCapBot())
1884
+ incoming = _FakeIncomingMessage(999984)
1885
+ incoming.message_id = 7
1886
+ media_data = {"images": ["http://x/1.jpg", "http://x/2.jpg", "http://x/3.jpg"]}
1887
+ try:
1888
+ asyncio.run(bot._try_send_tiktok_slideshow(
1889
+ None, media_data, incoming, None, "author", {},
1890
+ ))
1891
+ assert seen_caps
1892
+ assert all(cap is not None and cap <= 1500 for cap in seen_caps.values())
1893
+ assert all(cap <= lumen_tiktok.TIKTOK_DOWNLOAD_MAX_BYTES for cap in seen_caps.values())
1894
+ finally:
1895
+ bot.chat_state.pop(999984, None)
1896
+