- Fix SSE streams not terminating on successful jobs (C-2) - Anchor data/session paths to BASE_DIR instead of CWD (C-3) - Guard TelegramAuthManager state with RLock (H-1) - Replace millisecond job ids with uuid4 (H-2) - Always redact api_id/api_hash on export, drop include_secrets (H-3) - Enforce JSON content-type + same-origin on mutating requests (H-4) - Rate-limit auth attempts and phone-code requests (H-5) - Deep-copy StateStore.load() on all paths (H-6) - Cap FloodWait retries in forward_message (H-7) - De-duplicate forwarding handler registration (H-8) - Validate continuous channels at ingest, join scrape thread on account removal, fix refresh_config status under lock, cap SSE streams and JSON body size (M-5, M-6, M-17) - Add regression tests (33 passing) and REVIEW.md
19 KiB
REVIEW.md — telegram-scraper
Дата: 2026-09-07 Скоуп: полный аудит кода — безопасности и корректности (webui_server.py, app_state.py, scraper_jobs.py, telegram_scraper_with_forwarding.py, main.py, health.py, webui/*.js, деплой, тесты).
Статус: что уже исправлено
10 пунктов (критичные/высокие) исправлены 07.09.2026. Подробности — в истории коммита.
| ID | Проблема | Файл | Статус |
|---|---|---|---|
| C-2 | SSE-поток не завершался при успешном job (done vs completed) — утечка потоков до 30 мин |
webui_server.py | ✅ fixed |
| C-3 | Пути Path("data")/Path("session") относительно CWD расходились с BASE_DIR webui — тихая рассинхронизация данных |
telegram_scraper_with_forwarding.py, main.py, scraper_jobs.py | ✅ fixed |
| H-1 | TelegramAuthManager.lock объявлен, но не использовался — гонки на auth_data/clients между event-loop и HTTP-потоками |
webui_server.py | ✅ fixed (RLock) |
| H-2 | Коллизия job_id из миллисекундного timestamp |
webui_server.py | ✅ fixed (uuid4) |
| H-3 | Экспорт секретов через include_secrets=1 (api_hash/api_id) |
webui_server.py | ✅ fixed (всегда redact) |
| H-4 | CSRF: любой Content-Type, нет Origin/Sec-Fetch-Site проверки | webui_server.py | ✅ fixed (415 + same-origin 403) |
| H-5 | Нет rate limiting на брутфорс phone-code/2FA | webui_server.py | ✅ fixed (5 попыток → 60s lockout, 429) |
| H-6 | StateStore.load() возвращал мелкую копию — расшаренная мутация вложенных dict между потоками |
app_state.py | ✅ fixed (deepcopy) |
| H-7 | Бесконечная рекурсия forward_message при FloodWaitError |
telegram_scraper_with_forwarding.py | ✅ fixed (cap 3 retry) |
| H-8 | Повторная регистрация forward-хендлера → сообщения форвардились N раз | telegram_scraper_with_forwarding.py | ✅ fixed (remove_event_handler) |
Бонус при фиксах: migrate_database больше не глотает исключения (логирует), миграция покрывает все колонки MessageData; фронтенд app.js корректно распознаёт 'done' как терминальный статус.
Follow-up (второй проход по итогам ревью фиксов)
| ID | Проблема | Статус |
|---|---|---|
| H-6 | deepcopy теперь на всех путях load() (cache-hit + cache-miss + fallback-ветки) |
✅ fixed |
| H-5 | cooldown 30s на успешный запрос кода (анти-SMS-флуд) + _auth_attempts ограничен (sweep при >10k записей) |
✅ fixed |
| — | Origin-проверка на GET /api/jobs/{id}/events (403 до открытия SSE) |
✅ fixed |
| — | SSE-потоки на аккаунт ограничены (MAX_EVENT_STREAMS=10, revoke самого старого) — закрыт thread-exhaustion |
✅ fixed |
| — | read_json_body: кап тела 1 MB → 413 (sentinel), malformed Content-Length → не 500, не-UTF-8 → 400 |
✅ fixed (M-2 закрыт) |
| — | +6 регрессионных тестов (H-4/H-5/H-6/C-2, sweep, oversize body) | ✅ fixed |
Тесты: 29 passed (все зелёные после фиксов).
Follow-up (третий проход)
| ID | Что исправлено | Статус |
|---|---|---|
| M-17 | clean_continuous_channels() — валидация каналов на приёме в обоих POST-эндпоинтах (per-account + legacy); невалидные отбрасываются и возвращаются как dropped_invalid (drop, не 400 — фронтенд api() бросает на non-OK) |
✅ fixed |
| M-5 | PerAccountContinuousScrapeManager.join(timeout=20) + remove_account делает stop()+join перед rmtree; stop() идемпотентен; loop проверяет stop event на границах итераций |
✅ fixed |
| M-6 | self.config присваивается под локом; refresh_config больше не врёт о running (только stop_event); running=False ставится в finally потока при реальном выходе |
✅ fixed |
| — | +4 теста (33 passed) | ✅ fixed |
ОСТАВШИЕСЯ НАХОДКИ
🔴 КРИТИЧНО — C-1. Нет аутентификации на веб-панели, bind 0.0.0.0 + публичный ingress
Файл/строки: webui_server.py:47-48 (DEFAULT_HOST=0.0.0.0), весь роутинг без auth-check, compose.yaml:11-12 (порт 7887 на всех интерфейсах), k8s/telegram-scraper.yaml:76-89 (IngressRoute tg.workstation.internal без middleware/basicAuth).
Любой, кто достаёт порт/домен, может:
- прочитать
api_hash/api_id(через export — теперь redact, но есть и другие пути, см. M-1:/media/accounts/<id>/state.json), - прочитать QR-токен авторизации и угнать Telegram-сессию владельца,
- подменить креды, удалить аккаунт (
DELETE /api/accounts/{id}→shutil.rmtree), - читать все чаты, медиа, логи, continuous-scrape состояние.
Не исправлено (сознательно — требует архитектурного решения). Рекомендуемый порядок:
BasicAuth/ForwardAuth/OIDC на Traefik IngressRoute (быстро, закрывает сетевой доступ).- App-level сессионная авторизация (cookie + random token), проверка в
do_GET/do_POST/do_DELETEдо диспатча. - Дефолт bind
127.0.0.1+ не публиковать 7887 на всех интерфейсах. - После ввода auth — пересмотреть M-1 (см. ниже), который сейчас маскируется отсутствием auth.
🟠 СРЕДНИЕ
| # | Файл:строка (актуально) | Проблема | Предложение |
|---|---|---|---|
| M-1 | webui_server.py:2829+ (serve_media) |
/media/ рутится в DATA_DIR целиком: GET /media/accounts/<id>/state.json отдаёт api_hash (plaintext), /media/accounts/<id>/<ch>/*.db — базы. Conтент-проверки нет, только containment |
Требовать сегмент media/ в пути после account/channel; запретить state.json, *.db, *.session |
read_json_body |
✅ закрыт follow-up: кап 1 MB → 413, try/except, не-UTF-8 → 400 | ||
| M-3 | webui_server.py (много: 2168-2176, 2188, 2195, 2484, 2709, 2716) | bool(body.get("value"/"enabled"/"run_all_tracked")) — строка "false"/"0" приходит как True. Фиксы H-4 не тронули эти места |
Общий хелпер parse_bool(): True для true/1/yes/on |
| M-4 | webui_server.py do_HEAD (2120) + stream_job_events (2064) |
HEAD на /api/jobs/{id}/events для несуществующего job → 200 вместо 404 |
Валидировать job до HEAD |
ContinuousScrapeOrchestrator.remove_account) |
stop() только ставит event → shutil.rmtree может удалить DB/media, которые поток ещё пишет |
✅ fixed | |
refresh_config) |
status["running"]=False, пока поток ещё крутится (status врёт); update() присваивает self.config вне лока |
✅ fixed | |
| M-7 | webui_server.py:404-426 (JobRunner.shutdown) |
Очередные jobs остаются "queued" навсегда (worker выходит, не дрена́я очередь) |
Дрена́ж + пометить "failed"/"cancelled" на shutdown |
| M-8 | webui_server.py:2879+ (_parse_range) |
Мульти-диапазоны bytes=0-1,5-6 → 416; bytes=0-0 на пустом файле → 416 |
Обработать single-range случаи корректно |
| M-9 | webui_server.py legacy endpoints + webui_server.py:116-120 (load_state/save_state через STATE_STORE) vs app_state.py:123-131 (_GLOBAL_STORE) |
Два независимых StateStore на один файл data/state.json — расхождение TTL-кэшей до 1s, конфликтные .tmp. Legacy GET /api/channels//api/dashboard после миграции читают пустой глобальный state (не делегируют в migrated account) |
Свести к единому store; legacy GET — делегировать в legacy_account_id |
| M-10 | app_state.py:72-81 (save) |
Нет fsync перед rename (потеря питания → пустой/битый файл); фиксированное имя .tmp (два писателя в файл клообьют друг друга) |
flush()+os.fsync() перед replace; уникальные tmp-имена (tempfile) |
| M-11 | scraper_jobs.py:14-25 | asyncio.run() на каждый job — RuntimeError при вызове из потока с существующим loop (e.g. auth loop thread); set_scrape_media пишет в глобальный STATE_STORE вместо per-account |
Выделенный поток с new_event_loop() / per-account клиент-пул |
| M-12 | telegram_scraper_with_forwarding.py (scrape_channel) | Держит все media-объекты в памяти за весь проход (100k+ сообщений в большом канале) | Пакетная обработка media (как batch_insert) |
| M-13 | telegram_scraper_with_forwarding.py:127-131 (save_state) |
Перезапись всего per-account JSON каждые 50 сообщений — сотни сериализаций на длинный канал | Throttle до 5s / писать только в конце |
| M-14 | telegram_scraper_with_forwarding.py (existing_files glob) | Первое произвольное совпадение {id}-* может быть stale/частичным файлом |
Матчить точное имя / проверять non-empty |
| M-15 | health.py:73-83 | /health читает глобальный state: в multi-account режиме всегда has_api_credentials: false, tracked_channels: 0 — вводит в заблуждение |
Агрегировать per-account проверки |
| M-16 | webui_server.py(s) + k8s | Контейнер в k8s без securityContext (root, r/w FS, нет limits); в Dockerfile нет USER (compose задаёт 1000:1000, k8s — нет) |
runAsNonRoot: true, readOnlyRootFilesystem: true + resources.limits |
/api/continuous и /api/accounts/{id}/continuous) |
str().strip() без normalize_channel_id — безопасно только пока фильтрует _resolve_channels по tracked |
✅ fixed | |
| M-18 | data/ и session/ (хост) | root:root 755, state.json пишется 644 — session-файлы Telethon (полные auth-ключи) и api_hash читаемы локальными юзерами |
chmod 700 на data/session; StateStore пишет 0600 |
| M-19 | webui_server.py:1860-1862, 2011-2013 и др. | int(query...) без try/except → ValueError убивает поток + traceback в stderr; многие хендлеры эхат str(exc) (абс-пути в ответах) |
try/except → 400 JSON; ред.актировать пути из ответов |
| M-20 | webui_server.py (все POST) | CSRF-фикс (H-4) закрыл Origin/Content-Type, но CSRF-токенов per-session нет; при вводе реальной auth (C-1) нужны | CSRF-token + SameSite cookies после C-1 |
⚪ НИЗКИЕ
| # | Файл | Проблема |
|---|---|---|
| L-1 | webui_server.py:2776-2782 (access log) | Логируется весь self.path с query-параметрами (поисковые запросы и т.п.) |
| L-2 | webui/swagger.js:52 | innerHTML с ошибкой из /openapi.json (низкий риск — серверный контент) |
| L-3 | requirements.txt | Зависимости корректны (aiohttp 3.12.14 — патч CVE-2025-53643), но Telethon 1.40.0 (есть ~1.44.x); добавить uv audit/pip-audit в CI |
| L-4 | webui_server.py send_json/serve_file | Нет security-заголовков: CSP, X-Content-Type-Options, X-Frame-Options/frame-ancestors, Referrer-Policy (clickjacking актуален после ввода auth) |
| L-5 | webui_server.py auth snapshots (582-589) | QR-токен и его изображение висят в snapshot до сканирования — one-time + expiry ~60s |
| L-6 | webui_server.py:2147-2173 (legacy channels add/remove) | Паттерн load→save вместо StateStore.update() — lost-update race между потоками |
| L-7 | telegram_scraper_with_forwarding.py:838-841 | Прогресс-бар врут на инкрементальных прогонах (total vs only-new) — косметика |
| L-8 | webui_server.py _check_same_origin |
DNS-rebinding: Host == Origin.netloc проходит, если оба — домен атакующего (при rebinding Sec-Fetch-Site = same-origin). Закрыть allowlist'ом (localhost/127.0.0.1) или дефолт-bind 127.0.0.1 |
| L-9 | webui/app.js:944-962, webui/settings.js:248-266 | Import/export round-trip молча теряет api_id/api_hash (H-3 redact): UI не предупреждает, что креды нужно ввести заново после импорта |
Follow-up findings (round 3)
🟠 СРЕДНИЕ (from round-3 review)
| # | Файл:строка | Проблема | Предложение |
|---|---|---|---|
| F-1 | webui_server.py:2592, app_state.py:263 | Импорт аккаунта и legacy-миграция пишут continuous_scraping.channels как есть, минуя валидацию M-17 (латентно, т.к. _resolve_channels фильтрует по normalized tracked) |
Прогонять через clean_continuous_channels при импорте и миграции |
| F-2 | webui_server.py:2880 + webui/app.js:207-211 | dropped_invalid возвращается, но ни один JS его не читает — юзер не видит, что каналы отброшены |
В app.js при сохранении: if (resp.dropped_invalid?.length) toast(...) |
| F-3 | webui_server.py:1048-1058 | Enable во время drain: start() early-return по status["running"], потом finally ставит False — аккаунт enabled=True, но мёртв до ручного переключения |
start() проверять thread.is_alive() или ждать drain через join() перед стартом |
| F-4 | webui_server.py:1246-1254 | remove_account удаляет менеджера даже при таймауте join — recreate того же id создаёт второй воркер поверх живого (дубли) |
Pop только если join() вернул True; иначе tombstone |
⚪ НИЗКИЕ (from round-3 review)
| # | Файл | Проблема |
|---|---|---|
| F-5 | webui_server.py:237-238 | channels: null/не-список молча стирает весь список каналов (([], [])) — рассмотреть 400 на malformed payload |
| F-6 | webui_server.py:987-991 | refresh_config/_save_config стрипают, но не нормализуют — @-значения с диска (import/migration) никогда не матчатся с normalized tracked, молча не скрейпятся |
| F-7 | webui_server.py:1104-1171 | Нет верхнего except в _run_loop: исключение в refresh/auth-check убивает поток с последним_error нетронутым |
| F-8 | tests/test_integration.py:441-477 | Новые тесты не покрывают join()→False (таймаут) и start-during-drain; assert running is True после stop завязан на GIL-timing |
🧪 Пробелы в тестах
Покрыто новыми тестами (round 2, +6): deepcopy-изоляция load() (все пути), Content-Type/oversize в read_json_body, same-origin проверка, rate limiter (lockout + cooldown кода), sweep _auth_attempts, терминальные статусы SSE.
Осталось:
- tests/test_integration.py:29-38 — весь scraper-движок замокан (
sys.modules["telegram_scraper_with_forwarding"] = MagicMock()): реальный код (media naming, flood, forwarding, DB миграция, session) не покрыт вообще. Рекомендация: ставить telethon в CI и импортировать реальный модуль. - tests/test_integration.py:176-201 — тест удаления аккаунта дублирует логику хендлера инлайн, не вызывает продакшн-путь → регрессии в
_handle_delete_accountне ловятся. - Нет тестов на:
StateStoreTTL/atomic-write/конкурентныйupdate();_parse_range/ Range-ответы;normalize_media_url/guess_media_kind; 404 SSE; auth-флоу (QR/phone/2FA state machine); H-1 (lock-дисциплина); H-7/H-8 (scraper engine — упирается в полный мок движка). - tests/test_integration.py:48-50 — мутация
os.environна уровне импорта (leak между модулями). Лучшеmonkeypatch. - tests/test_integration.py:337-397 —
START_CONTINUOUS = Falseвыставляется в setup и не восстанавливается.
✅ Проверено — уязвимостей НЕТ
- Path traversal:
serve_media/serve_static—resolve()+relative_to()(корректно, включая symlink);normalize_channel_idотвергает/,\, control chars,./... - SQL injection: все запросы параметризованы,
search— черезLIKE ?. - XSS: viewer.js рендерит контент через
textContent/createTextNode; media-URL всегда префиксуется/media/(нетjavascript:схемы). - SSRF: юзер-контролируемого фетча URL нет (только MTProto).
- Десериализация: только JSON, без pickle/yaml.
- Command injection: нет subprocess/os.system в продакшн-путях.
- Secrets в image:
.dockerignoreисключаетdata/иsession/.