From b1563b86cbb14b364fce894511e2938778aed2c2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 1 Aug 2026 21:41:12 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(tradein/proxy):=20=D1=80=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20exit-IP=20ASocks=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=BD=D1=83=20=D1=81=D0=BE=20=D1=81=D1=87=D1=91?= =?UTF-8?q?=D1=82=D1=87=D0=B8=D0=BA=D0=BE=D0=BC=20=D0=B8=20=D0=B3=D1=80?= =?UTF-8?q?=D0=BE=D0=BC=D0=BA=D0=B8=D0=BC=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=BC=20(#2600)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/admin.py | 42 ++ tradein-mvp/backend/app/core/config.py | 12 + .../backend/app/services/proxy_rotation.py | 311 ++++++++++++++ .../data/sql/198_scrape_proxy_rotations.sql | 49 +++ .../199_scrape_proxies_asocks_rotate_url.sql | 58 +++ .../tests/services/test_proxy_rotation.py | 388 ++++++++++++++++++ 6 files changed, 860 insertions(+) create mode 100644 tradein-mvp/backend/app/services/proxy_rotation.py create mode 100644 tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql create mode 100644 tradein-mvp/backend/data/sql/199_scrape_proxies_asocks_rotate_url.sql create mode 100644 tradein-mvp/backend/tests/services/test_proxy_rotation.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 5ab89505..01dc273d 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -70,6 +70,7 @@ from app.core.db import SessionLocal, get_db from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate from app.services import cian_session as cian_session_svc from app.services import domclick_session as domclick_session_svc +from app.services import proxy_rotation as proxy_rotation_svc from app.services import scrape_runs as runs_mod from app.services.geocoder import geocode from app.services.scheduler import has_running_run @@ -2889,3 +2890,44 @@ def patch_proxy( created_at=_iso(row["created_at"]), updated_at=_iso(row["updated_at"]), ) + + +# ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ─────────────── +# +# ОТДЕЛЬНО от /scraper/{source}/rotate-ip (выше) — тот работает по env-прокси +# mobileproxy для avito/cian/yandex (changeip-ссылка, ротация "на лету" без +# лимитов), не трогается. Этот эндпоинт — по proxy_id из пула scrape_proxies +# (сейчас это ASocks-порты с суточным лимитом 3/сутки), см. +# app.services.proxy_rotation.rotate_proxy. + + +class ProxyRotateResponse(BaseModel): + ok: bool + reason: str | None = None + new_ip: str | None = None + rotations_remaining_today: int + + +@router.post("/proxies/{proxy_id}/rotate", response_model=ProxyRotateResponse) +async def rotate_pool_proxy( + proxy_id: int, + db: Annotated[Session, Depends(get_db)], +) -> ProxyRotateResponse: + """Ручная ротация exit-IP одного прокси пула (#2600 п.5). + + Делегирует в app.services.proxy_rotation.rotate_proxy — читает rotate_url + прокси из scrape_proxies, требует ASOCKS_API_TOKEN (settings.asocks_api_token), + проверяет суточный лимит (3/сутки, scrape_proxy_rotations) ДО обращения к API. + ok=False — ожидаемая бизнес-ситуация (нет rotate_url / нет токена / лимит / + провайдер отказал), НЕ HTTPException; reason ВСЕГДА нейтральный, без токена. + + ПОКА без автотриггера по бану (issue #2600 п.2: сигнал бана до пула не + доходит — страница-заглушка отдаёт 200) — только этот ручной вызов. + """ + result = await proxy_rotation_svc.rotate_proxy(db, proxy_id) + return ProxyRotateResponse( + ok=result.ok, + reason=result.reason, + new_ip=result.new_ip, + rotations_remaining_today=result.rotations_remaining_today, + ) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 7ee6f9e5..491c4600 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -548,6 +548,18 @@ class Settings(BaseSettings): proxy_rotate_attempt_timeout_s: float = 8.0 proxy_rotate_attempts: int = 3 + # ── ASocks pool-proxy rotation (#2600) ─────────────────────────────────── + # Bearer-токен веб-кабинета ASocks для POST .../unlimited-proxy/{portId}/refresh-ip + # (app.services.proxy_rotation). Документированный публичный API (GET + # /v2/proxy/refresh/{portId}?apiKey=) для безлимитных портов не работает — + # подтверждено владельцем аккаунта; единственный рабочий путь — эта ручка + # веб-кабинета с сессионным токеном. Токен разово протухнет (осознанное + # решение владельца) — тогда provider вернёт 401, proxy_rotation.rotate_proxy + # логирует error + шлёт Sentry/GlitchTip alert. Пусто = ротация для всех + # прокси недоступна (rotate_proxy возвращает внятный отказ, не падает). + # ENV: ASOCKS_API_TOKEN. НИКОГДА не логировать / не возвращать в HTTP-ответе. + asocks_api_token: str = Field(default="", validation_alias="ASOCKS_API_TOKEN") + # #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses, # ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний. # False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED. diff --git a/tradein-mvp/backend/app/services/proxy_rotation.py b/tradein-mvp/backend/app/services/proxy_rotation.py new file mode 100644 index 00000000..9ff922c8 --- /dev/null +++ b/tradein-mvp/backend/app/services/proxy_rotation.py @@ -0,0 +1,311 @@ +"""Ротация exit-IP прокси ASocks по требованию, со счётчиком и громким отказом (#2600 п.5). + +АДДИТИВНО. НЕ трогает app.services.proxy_pool (pick/lease/health — параллельный +PR #2609, конфликт исключён: вся новая логика тут, в новом модуле). + +Контекст (эмпирика, issue #2600 п.5 — проверено владельцем аккаунта/пробой): + - Документированный публичный API ASocks (GET /v2/proxy/refresh/{portId}?apiKey=) + для безлимитных портов НЕ работает. + - Ротация сменой session-суффикса логина (-session-N) НЕ работает — exit-IP + не меняется (три варианта дали один и тот же IP). + - Единственный рабочий путь — ручка веб-кабинета: + POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip + Authorization: Bearer <токен> + Без заголовка провайдер отдаёт 401 {"success": false, "message": "Unauthenticated"}. + scrape_proxies.rotate_url уже несёт этот URL (миграция 199) — токен НЕ в URL, + он только в ASOCKS_API_TOKEN (env, app.core.config.settings.asocks_api_token). + - Лимит провайдера: 3 ротации в сутки на порт. + - Токен — сессионный, однажды протухнет (осознанное решение владельца аккаунта). + Когда это случится, провайдер ответит 401 — это ГРОМКИЙ отказ ниже + (logger.error + Sentry/GlitchTip capture_message), а не молчаливая остановка. + +Суточный лимит и таблица истории (scrape_proxy_rotations, миграция 198): + Против лимита 3/сутки считаются ТОЛЬКО попытки, реально дошедшие до провайдера + и обработанные им — т.е. любой HTTP-ответ провайдера, КРОМЕ 401. Обоснование: + 401 — это буквально описание провайдера "Unauthenticated": запрос отсеян на + уровне аутентификации ДО обращения к самой логике ротации порта, провайдер не + мог засчитать использование ротации тому, кого даже не подтвердил. Сетевые + ошибки (таймаут / разрыв соединения — ответа вообще нет) по той же логике не + считаются: нет подтверждения, что запрос вообще дошёл до провайдера. Локальные + отказы (нет rotate_url / нет токена / лимит уже исчерпан) до HTTP-вызова не + доходят вовсе — в таблицу не пишутся и лимит не трогают. + + quota-consuming := http_status IS NOT NULL AND http_status != 401 + (успех 200 И любой не-401 ответ провайдера, включая его собственные 4xx/5xx — + если провайдер прошёл auth и ответил бизнес-ошибкой, запрос точно дошёл до + реальной rotate-логики и мог быть учтён в лимите на его стороне). + +⛔ Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте +исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через +str(exc) — см. комментарий в app.api.v1.admin.rotate_proxy_ip (~line 2400): +httpx-исключения несут полный request URL/детали, поэтому наружу — только +нейтральный reason, полные детали — в лог с exc_info=True. + +psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +import httpx +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +__all__ = [ + "DAILY_ROTATION_LIMIT", + "RotationResult", + "rotate_proxy", +] + +# Лимит провайдера (ASocks, безлимитные порты): 3 ротации в сутки на порт (эмпирика). +DAILY_ROTATION_LIMIT = 3 + +# Таймаут POST refresh-ip. Пункт задачи требует "~30с". +_ROTATE_TIMEOUT_S = 30.0 + + +@dataclass +class RotationResult: + """Результат попытки ротации exit-IP одного прокси. reason — ВСЕГДА нейтральный + (безопасен для HTTP-ответа клиенту), никогда не несёт токен/секреты.""" + + ok: bool + reason: str | None + new_ip: str | None = None + # Сколько quota-consuming попыток остаётся сегодня ПОСЛЕ этой попытки (см. модуль + # docstring за определением quota-consuming). Для локально отклонённых попыток + # (no rotate_url/no token) не относится к текущему прокси — просто текущий остаток. + rotations_remaining_today: int = DAILY_ROTATION_LIMIT + + +def _quota_used_today(db: Session, proxy_id: int) -> int: + """Число quota-consuming попыток за последние 24ч (см. docstring модуля). + + http_status IS NOT NULL AND != 401 — успех И любой не-401 ответ провайдера. + 401 (auth-отсев) и сетевые ошибки (http_status IS NULL) не считаются. + """ + row = ( + db.execute( + text( + """ + SELECT count(*) AS n + FROM scrape_proxy_rotations + WHERE proxy_id = CAST(:proxy_id AS bigint) + AND rotated_at > now() - interval '24 hours' + AND http_status IS NOT NULL + AND http_status != 401 + """ + ), + {"proxy_id": proxy_id}, + ) + .mappings() + .fetchone() + ) + return int(row["n"]) if row is not None else 0 + + +def _record_attempt( + db: Session, + proxy_id: int, + *, + success: bool, + http_status: int | None, + note: str | None, +) -> None: + """Записать попытку ротации в аудит-таблицу. Вызывается ТОЛЬКО когда HTTP-запрос + к провайдеру реально был сделан (локально отклонённые попытки не пишутся — + см. модуль docstring).""" + db.execute( + text( + """ + INSERT INTO scrape_proxy_rotations (proxy_id, success, http_status, note) + VALUES ( + CAST(:proxy_id AS bigint), + CAST(:success AS boolean), + CAST(:http_status AS integer), + CAST(:note AS text) + ) + """ + ), + {"proxy_id": proxy_id, "success": success, "http_status": http_status, "note": note}, + ) + db.commit() + + +def _alert_stale_token(proxy_id: int) -> None: + """Громкий отказ на 401: logger.error + событие в Sentry/GlitchTip (best-effort). + + 401 значит, что провайдер отверг Authorization-заголовок — токен протух (issue + #2600 п.5: "Токен — сессионный, однажды протухнет. Это осознанное решение + владельца"). Молчаливая остановка ротации недопустима — операторы должны узнать + об этом сразу, а не когда прокси уже забанены неделю. + """ + logger.error( + "proxy_rotation: ASocks REJECTED Authorization (401) for proxy_id=%d — " + "ASOCKS_API_TOKEN likely EXPIRED, IP rotation is now BLOCKED for this proxy " + "until the token is refreshed in web-cabinet + env", + proxy_id, + ) + try: + import sentry_sdk + + sentry_sdk.capture_message( + f"ASocks rotation token rejected (401) for proxy_id={proxy_id} — " + "ASOCKS_API_TOKEN expired, IP rotation blocked until refreshed", + level="error", + ) + except Exception: + pass # sentry_sdk not initialised in dev — best-effort only + + +def _extract_new_ip(resp: httpx.Response) -> str | None: + """Best-effort вытащить новый exit-IP из ответа провайдера. Формат ответа + refresh-ip для безлимитных портов ASocks не документирован (issue #2600 п.5) — + парсинг заведомо defensive, неудача не является ошибкой ротации.""" + try: + data: Any = resp.json() + except Exception: + return None + if not isinstance(data, dict): + return None + for key in ("new_ip", "ip", "exit_ip"): + val = data.get(key) + if val: + return str(val) + nested = data.get("data") + if isinstance(nested, dict): + for key in ("new_ip", "ip", "exit_ip"): + val = nested.get(key) + if val: + return str(val) + return None + + +async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult: + """Сменить exit-IP одного прокси пула через ASocks refresh-ip (#2600 п.5). + + Порядок: + 1. proxy_id не найден в scrape_proxies → ok=False, reason нейтральный. + 2. rotate_url пусто → ok=False, "ротация не поддерживается" (НЕ ошибка). + 3. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False, + внятный отказ, ничего не ломается. + 4. Суточный лимит (см. _quota_used_today) исчерпан → ok=False, отказ БЕЗ + обращения к API. + 5. POST rotate_url с Authorization: Bearer , timeout ~30с. + - Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL + (НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True. + - 401 → громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается + в лимите), нейтральный reason. + - Другой 4xx/5xx → аудит-запись (считается в лимите — провайдер прошёл + auth и ответил своей бизнес-логикой), нейтральный reason. + - 2xx → аудит-запись success=True (считается в лимите), new_ip best-effort. + + Ни в одном из reason/логов НЕ появляется токен. + """ + row = ( + db.execute( + text("SELECT id, rotate_url FROM scrape_proxies WHERE id = CAST(:id AS bigint)"), + {"id": proxy_id}, + ) + .mappings() + .fetchone() + ) + if row is None: + return RotationResult(ok=False, reason="proxy not found") + + rotate_url = row["rotate_url"] + if not rotate_url: + logger.info( + "proxy_rotation: proxy_id=%d has no rotate_url — rotation not supported", proxy_id + ) + return RotationResult( + ok=False, reason="rotation not supported for this proxy (no rotate_url configured)" + ) + + token = settings.asocks_api_token + if not token: + logger.warning( + "proxy_rotation: ASOCKS_API_TOKEN not configured — proxy_id=%d rotation skipped", + proxy_id, + ) + return RotationResult(ok=False, reason="rotation not configured (missing API token)") + + used = _quota_used_today(db, proxy_id) + if used >= DAILY_ROTATION_LIMIT: + logger.warning( + "proxy_rotation: daily limit reached proxy_id=%d used=%d/%d — skipping API call", + proxy_id, + used, + DAILY_ROTATION_LIMIT, + ) + return RotationResult( + ok=False, + reason=f"daily rotation limit reached ({DAILY_ROTATION_LIMIT}/day)", + rotations_remaining_today=0, + ) + + try: + async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client: + resp = await client.post(rotate_url, headers={"Authorization": f"Bearer {token}"}) + except Exception: + # Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера, + # значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести + # служебные детали соединения) — только exc_info=True в лог. + logger.warning( + "proxy_rotation: request failed (no response) proxy_id=%d", proxy_id, exc_info=True + ) + _record_attempt( + db, proxy_id, success=False, http_status=None, note="request failed (no response)" + ) + return RotationResult( + ok=False, + reason="rotation request failed (network error)", + rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used), + ) + + status = resp.status_code + + if status == 401: + _alert_stale_token(proxy_id) + _record_attempt( + db, + proxy_id, + success=False, + http_status=401, + note="unauthenticated — token expired/invalid (excluded from daily quota)", + ) + return RotationResult( + ok=False, + reason="rotation service rejected credentials — alerted, contact operator", + rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used), + ) + + if status >= 400: + logger.warning( + "proxy_rotation: provider returned error proxy_id=%d status=%d", proxy_id, status + ) + _record_attempt( + db, proxy_id, success=False, http_status=status, note="provider returned error" + ) + return RotationResult( + ok=False, + reason=f"rotation request failed (provider status {status})", + rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)), + ) + + new_ip = _extract_new_ip(resp) + logger.info("proxy_rotation: rotated proxy_id=%d status=%d new_ip=%s", proxy_id, status, new_ip) + _record_attempt(db, proxy_id, success=True, http_status=status, note=None) + return RotationResult( + ok=True, + reason=None, + new_ip=new_ip, + rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)), + ) diff --git a/tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql b/tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql new file mode 100644 index 00000000..870d85bc --- /dev/null +++ b/tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql @@ -0,0 +1,49 @@ +-- 198_scrape_proxy_rotations.sql +-- Issue #2600 п.5 — ротация exit-IP прокси ASocks по бану, со счётчиком и громким +-- отказом. АДДИТИВНО, не трогает scrape_proxies (157_scrape_proxies.sql) кроме +-- FK-ссылки; не трогает proxy_pool.py (параллельный PR #2609). +-- +-- WHY: +-- Провайдер (ASocks, безлимитные порты) ограничивает ручную ротацию exit-IP тремя +-- вызовами в сутки на порт (эмпирика, владелец аккаунта). app.services.proxy_rotation +-- должен и проверять этот лимит ПЕРЕД обращением к API, и вести аудит попыток — +-- без отдельной таблицы истории лимит негде считать (scrape_proxies хранит только +-- текущее состояние, не историю). +-- +-- Semantics: +-- Одна строка = одна попытка ротации (успешная ИЛИ неуспешная), но НЕ каждый +-- вызов rotate_proxy() пишет строку — локально отклонённые попытки (нет +-- rotate_url / нет ASOCKS_API_TOKEN / лимит уже исчерпан) вообще не доходят до +-- HTTP-вызова и в таблицу не пишутся (см. app.services.proxy_rotation docstring +-- за полным обоснованием "какие попытки считать против лимита"). +-- http_status NULL = сетевая ошибка (ответа от провайдера не было вообще). +-- +-- Idempotency: +-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS → повторный прогон +-- no-op (auto-apply strict на деплое это требует). Весь файл в BEGIN/COMMIT. +-- +-- Dependencies: +-- 157_scrape_proxies.sql (scrape_proxies.id — FK-таргет). + +BEGIN; + +CREATE TABLE IF NOT EXISTS scrape_proxy_rotations ( + id bigserial PRIMARY KEY, + proxy_id bigint NOT NULL REFERENCES scrape_proxies (id), + rotated_at timestamptz NOT NULL DEFAULT now(), + success boolean NOT NULL, + http_status integer, + note text +); + +COMMENT ON TABLE scrape_proxy_rotations IS + 'Аудит + суточный лимит (#2600 п.5) ручных ротаций exit-IP через ASocks ' + 'refresh-ip. Лимит провайдера — 3 попытки/сутки на порт; app.services.' + 'proxy_rotation._quota_used_today считает только строки с http_status ' + 'IS NOT NULL AND != 401 (реально дошедшие до провайдера) за последние 24ч.'; + +-- Проверка суточного лимита + выборка истории по прокси: (proxy_id, rotated_at). +CREATE INDEX IF NOT EXISTS idx_scrape_proxy_rotations_proxy_time + ON scrape_proxy_rotations (proxy_id, rotated_at); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/199_scrape_proxies_asocks_rotate_url.sql b/tradein-mvp/backend/data/sql/199_scrape_proxies_asocks_rotate_url.sql new file mode 100644 index 00000000..15c8e0dd --- /dev/null +++ b/tradein-mvp/backend/data/sql/199_scrape_proxies_asocks_rotate_url.sql @@ -0,0 +1,58 @@ +-- 199_scrape_proxies_asocks_rotate_url.sql +-- Issue #2600 п.5 — проставить rotate_url для четырёх ASocks unlimited-портов пула, +-- чтобы app.services.proxy_rotation.rotate_proxy имел куда стучаться. +-- +-- WHY: +-- scrape_proxies.rotate_url для этих 4 строк сейчас NULL (загружены через +-- POST /proxies/bulk без rotate_url). Единственный рабочий способ ротации exit-IP +-- для ASocks-безлимитных портов — ручка веб-кабинета +-- POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip с заголовком +-- Authorization: Bearer (env, НЕ в URL — секретов в миграции +-- нет). Документированный публичный GET /v2/proxy/refresh/{portId}?apiKey= для +-- безлимитных портов не работает (подтверждено владельцем аккаунта); ротация +-- session-суффиксом логина тоже не работает (проверено пробой, три варианта — +-- один и тот же exit-IP). +-- +-- Matching (важно — НЕ по id): +-- scrape_proxies.id может разъехаться между средами (dev/stage/prod грузятся +-- bulk-ручкой независимо) — сопоставляем по адресу host:port, зашитому в конец +-- url (scrape_proxies.url — всегда 'scheme://[user:pass@]host:port' БЕЗ пути, +-- см. admin.py _mask_proxy_url/urlparse-логику и 157_scrape_proxies.sql) через +-- right(url, length(hostport)) = hostport. portId → host:port (проверено +-- владельцем аккаунта, issue #2600 п.5): +-- 223610715 → 212.8.249.134:10423 +-- 225031312 → 190.2.145.131:10313 +-- 231878029 → 175.110.115.153:10492 +-- 231878030 → 109.236.82.42:11048 +-- +-- Idempotency: +-- Обычный UPDATE ... WHERE — повторный прогон пишет то же значение, no-op по +-- результату. Прокси, которых нет в пуле текущей среды (host:port не найден) — +-- 0 строк обновлено, не ошибка. Весь файл в BEGIN/COMMIT. +-- +-- Dependencies: +-- 157_scrape_proxies.sql (scrape_proxies.rotate_url). + +BEGIN; + +UPDATE scrape_proxies +SET rotate_url = 'https://api.asocks.com/unlimited-proxy/223610715/refresh-ip', + updated_at = now() +WHERE right(url, length(CAST('212.8.249.134:10423' AS text))) = '212.8.249.134:10423'; + +UPDATE scrape_proxies +SET rotate_url = 'https://api.asocks.com/unlimited-proxy/225031312/refresh-ip', + updated_at = now() +WHERE right(url, length(CAST('190.2.145.131:10313' AS text))) = '190.2.145.131:10313'; + +UPDATE scrape_proxies +SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878029/refresh-ip', + updated_at = now() +WHERE right(url, length(CAST('175.110.115.153:10492' AS text))) = '175.110.115.153:10492'; + +UPDATE scrape_proxies +SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878030/refresh-ip', + updated_at = now() +WHERE right(url, length(CAST('109.236.82.42:11048' AS text))) = '109.236.82.42:11048'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/services/test_proxy_rotation.py b/tradein-mvp/backend/tests/services/test_proxy_rotation.py new file mode 100644 index 00000000..a51adbd4 --- /dev/null +++ b/tradein-mvp/backend/tests/services/test_proxy_rotation.py @@ -0,0 +1,388 @@ +"""Offline-тесты ротации exit-IP ASocks (#2600 п.5). + +Покрытие БЕЗ live-сети/БД: httpx.AsyncClient подменён предсказуемым фейком, +FakeSession эмулирует scrape_proxies (одна строка) + scrape_proxy_rotations +(append-only список), pytest-asyncio (asyncio_mode=auto, см. pyproject.toml). + + - rotate_url пуст → «не поддерживается», НЕ ошибка, HTTP не дёргается. + - ASOCKS_API_TOKEN не задан → внятный отказ, HTTP не дёргается. + - 4-я попытка за сутки отклоняется БЕЗ обращения к API (лимит 3/сутки). + - Успешная ротация пишет запись в scrape_proxy_rotations (success=True). + - 401 → logger.error (громкий отказ) + sentry_sdk.capture_message (мониторинг), + аудит-запись пишется, но НЕ считается против суточного лимита. + - Токен не появляется ни в RotationResult.reason, ни в note аудит-записи — + ни в одном из сценариев (сеть-ошибка, 401, provider 5xx, success). +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import logging +from datetime import UTC, datetime, timedelta +from typing import Any + +import httpx +import pytest + +from app.services import proxy_rotation + +SECRET_TOKEN = "asocks-super-secret-token-must-never-leak-1a2b3c" + +# ── stateful fakes ──────────────────────────────────────────────────────────── + + +class _FakeResult: + def __init__(self, rows: list[dict[str, Any]]): + self._rows = rows + + def mappings(self) -> _FakeResult: + return self + + def fetchone(self) -> dict[str, Any] | None: + return self._rows[0] if self._rows else None + + +class FakeSession: + """Эмуляция Session: одна строка scrape_proxies + append-only + scrape_proxy_rotations, интерпретирует SQL по ключевым фрагментам (тот же + паттерн, что tests/services/test_proxy_pool.py).""" + + def __init__( + self, + proxy_row: dict[str, Any] | None, + rotations: list[dict[str, Any]] | None = None, + ): + self.proxy_row = proxy_row + self.rotations: list[dict[str, Any]] = rotations or [] + self.commits = 0 + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + sql = str(stmt) + p = params or {} + + if "SELECT id, rotate_url FROM scrape_proxies" in sql: + if self.proxy_row is None or self.proxy_row["id"] != p["id"]: + return _FakeResult([]) + return _FakeResult([dict(self.proxy_row)]) + + if "SELECT count(*) AS n" in sql and "scrape_proxy_rotations" in sql: + cutoff = datetime.now(UTC) - timedelta(hours=24) + n = sum( + 1 + for r in self.rotations + if r["proxy_id"] == p["proxy_id"] + and r["rotated_at"] > cutoff + and r["http_status"] is not None + and r["http_status"] != 401 + ) + return _FakeResult([{"n": n}]) + + if "INSERT INTO scrape_proxy_rotations" in sql: + self.rotations.append( + { + "proxy_id": p["proxy_id"], + "success": p["success"], + "http_status": p["http_status"], + "note": p["note"], + "rotated_at": datetime.now(UTC), + } + ) + return _FakeResult([]) + + raise AssertionError(f"unhandled SQL: {sql}") + + def commit(self) -> None: + self.commits += 1 + + def rollback(self) -> None: + pass + + +class _FakeResponse: + def __init__(self, status_code: int, json_data: dict[str, Any] | None): + self.status_code = status_code + self._json_data = json_data + + def json(self) -> dict[str, Any]: + if self._json_data is None: + raise ValueError("no json body") + return self._json_data + + +def _fake_async_client( + *, + response: tuple[int, dict[str, Any] | None] | None, + exception: Exception | None, +): + """Строит замену httpx.AsyncClient, никогда не бьющую в реальную сеть. + + Ровно один из (response, exception) задан. calls накапливает (url, headers) + каждого post() — тест проверяет по ним, был ли вообще HTTP-вызов. + """ + calls: list[dict[str, Any]] = [] + + class _FakeClientImpl: + def __init__(self, timeout: float | None = None) -> None: + self.timeout = timeout + + async def __aenter__(self) -> _FakeClientImpl: + return self + + async def __aexit__(self, *exc: object) -> bool: + return False + + async def post(self, url: str, headers: dict[str, str] | None = None) -> _FakeResponse: + calls.append({"url": url, "headers": headers or {}}) + if exception is not None: + raise exception + assert response is not None + status, body = response + return _FakeResponse(status, body) + + return _FakeClientImpl, calls + + +def _no_http_allowed(): + """httpx.AsyncClient-заглушка, падающая AssertionError при любом post() — + для сценариев, где HTTP до провайдера дойти НЕ должно.""" + + class _ForbiddenClient: + def __init__(self, timeout: float | None = None) -> None: + pass + + async def __aenter__(self) -> _ForbiddenClient: + return self + + async def __aexit__(self, *exc: object) -> bool: + return False + + async def post(self, *a: object, **kw: object) -> None: + raise AssertionError("HTTP call must NOT happen for this scenario") + + return _ForbiddenClient + + +_DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip" + + +def _proxy_row(rotate_url: str | None = _DEFAULT_ROTATE_URL) -> dict[str, Any]: + return {"id": 1, "rotate_url": rotate_url} + + +def _quota_rows(proxy_id: int, n: int, *, http_status: int = 200) -> list[dict[str, Any]]: + now = datetime.now(UTC) + return [ + { + "proxy_id": proxy_id, + "success": http_status < 400, + "http_status": http_status, + "note": None, + "rotated_at": now - timedelta(minutes=i), + } + for i in range(n) + ] + + +# ── no rotate_url → not an error ──────────────────────────────────────────── + + +async def test_no_rotate_url_is_not_an_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(_proxy_row(rotate_url=None)) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert result.reason is not None + assert "rotat" in result.reason.lower() # human-readable, not a crash + assert db.rotations == [] # ничего не писалось — попытки не было + + +# ── missing token → neutral refusal, no crash ─────────────────────────────── + + +async def test_missing_token_is_neutral_refusal(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", "") + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert result.reason is not None + assert db.rotations == [] + + +# ── daily limit ────────────────────────────────────────────────────────────── + + +async def test_fourth_attempt_today_rejected_without_api_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + # 3 quota-consuming попытки уже сегодня (успешные 200 — засчитываются). + db = FakeSession(_proxy_row(), _quota_rows(1, proxy_rotation.DAILY_ROTATION_LIMIT)) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert "limit" in (result.reason or "").lower() or "лимит" in (result.reason or "").lower() + assert result.rotations_remaining_today == 0 + # _no_http_allowed() would have raised AssertionError from within rotate_proxy + # if the code had tried an HTTP call — reaching here means it didn't. + assert len(db.rotations) == proxy_rotation.DAILY_ROTATION_LIMIT # ничего нового не дописано + + +# ── success writes history ────────────────────────────────────────────────── + + +async def test_successful_rotation_writes_history_row(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, calls = _fake_async_client(response=(200, {"ip": "9.9.9.9"}), exception=None) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is True + assert result.new_ip == "9.9.9.9" + assert result.rotations_remaining_today == proxy_rotation.DAILY_ROTATION_LIMIT - 1 + assert len(calls) == 1 + assert calls[0]["headers"]["Authorization"] == f"Bearer {SECRET_TOKEN}" + + assert len(db.rotations) == 1 + row = db.rotations[0] + assert row["success"] is True + assert row["http_status"] == 200 + assert db.commits >= 1 + + +# ── 401 → loud failure ─────────────────────────────────────────────────────── + + +async def test_401_logs_error_and_alerts_monitoring_excluded_from_quota( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, calls = _fake_async_client( + response=(401, {"success": False, "message": "Unauthenticated"}), exception=None + ) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + sentry_calls: list[tuple[str, str | None]] = [] + monkeypatch.setattr( + "sentry_sdk.capture_message", + lambda msg, level=None: sentry_calls.append((msg, level)), + ) + + db = FakeSession(_proxy_row()) + with caplog.at_level(logging.ERROR): + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert len(calls) == 1 # запрос реально ушёл + + # громкий отказ: и лог, и мониторинг — не молчаливая остановка + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + assert any("401" in r.getMessage() for r in error_records) + assert len(sentry_calls) == 1 + assert sentry_calls[0][1] == "error" + + # аудит записан, но 401 НЕ считается против суточного лимита (см. модуль + # docstring: auth-отсев до провайдера, лимит на его стороне не тратится). + assert len(db.rotations) == 1 + assert db.rotations[0]["http_status"] == 401 + assert db.rotations[0]["success"] is False + assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type] + + second = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + # 401 не съел лимит — снова полный DAILY_ROTATION_LIMIT доступен + assert second.rotations_remaining_today == proxy_rotation.DAILY_ROTATION_LIMIT + + +# ── token never leaks ──────────────────────────────────────────────────────── + + +async def test_token_never_appears_in_reason_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, _ = _fake_async_client(response=(200, {"ip": "1.1.1.1"}), exception=None) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + assert SECRET_TOKEN not in (result.reason or "") + assert SECRET_TOKEN not in (result.new_ip or "") + + +async def test_token_never_appears_in_reason_on_401(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr("sentry_sdk.capture_message", lambda *a, **kw: None) + fake_client, _ = _fake_async_client( + response=(401, {"message": "Unauthenticated"}), exception=None + ) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + assert SECRET_TOKEN not in (result.reason or "") + assert all(SECRET_TOKEN not in (r["note"] or "") for r in db.rotations) + + +async def test_token_never_appears_in_reason_on_network_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """httpx-исключения могут нести полный request-контекст (URL/детали) — + прецедент утечки: app.api.v1.admin.rotate_proxy_ip (~line 2400). Здесь токен + живёт только в headers (не в URL), но проверяем end-to-end: даже если + exception-текст содержит секрет (симулируем это явно), наружу он не идёт.""" + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + boom = httpx.ConnectError(f"connection failed while POSTing token={SECRET_TOKEN}") + fake_client, _ = _fake_async_client(response=None, exception=boom) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert SECRET_TOKEN not in (result.reason or "") + assert all(SECRET_TOKEN not in (r["note"] or "") for r in db.rotations) + # сетевая ошибка не подтверждает, что провайдер обработал попытку → квота не тратится + assert db.rotations[0]["http_status"] is None + assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type] + + +async def test_token_never_appears_on_provider_error_status( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, _ = _fake_async_client( + response=(500, {"message": "internal error"}), exception=None + ) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row()) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert SECRET_TOKEN not in (result.reason or "") + # провайдер прошёл auth и ответил своей ошибкой (500) — засчитывается в квоту + assert db.rotations[0]["http_status"] == 500 + assert proxy_rotation._quota_used_today(db, 1) == 1 # type: ignore[arg-type] + + +# ── proxy not found ────────────────────────────────────────────────────────── + + +async def test_unknown_proxy_id_returns_neutral_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(None) + result = await proxy_rotation.rotate_proxy(db, 999) # type: ignore[arg-type] + assert result.ok is False + assert result.reason is not None -- 2.45.3 From bed2b7bca9eb62ccca09ddebc454681820a822fa Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 1 Aug 2026 22:12:52 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(tradein/proxy):=20pin=20ASocks=20rotate?= =?UTF-8?q?=5Furl=20host=20=E2=80=94=20=D0=BD=D0=B5=20=D1=81=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=87=D1=83=D0=B6=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=BA=D1=81?= =?UTF-8?q?=D0=B8=20(#2600)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrape_proxies.rotate_url колонка неоднородна: прод несёт и mobileproxy changeip-ссылки (id 3/4/5), и ASocks-ссылки (id 1/9/10/11). Без явной проверки хоста Authorization: Bearer ушёл бы на чужой провайдер — security review PR #2611. Добавлен ALLOWED_ROTATE_HOST-пиннинг (https-only, хост == api.asocks.com) ДО HTTP-вызова; несовпадение — отказ, не безголовый запрос без Authorization (смысл ручной ротации — конкретный провайдер). Заодно: класс исключения (не секрет) в note сетевой ошибки — отличить ConnectError от ReadTimeout; расширено leak-покрытие на текст log/Sentry сообщений (не только reason/note). --- .../backend/app/services/proxy_rotation.py | 71 +++++++++- .../tests/services/test_proxy_rotation.py | 123 +++++++++++++++++- 2 files changed, 187 insertions(+), 7 deletions(-) diff --git a/tradein-mvp/backend/app/services/proxy_rotation.py b/tradein-mvp/backend/app/services/proxy_rotation.py index 9ff922c8..ad711655 100644 --- a/tradein-mvp/backend/app/services/proxy_rotation.py +++ b/tradein-mvp/backend/app/services/proxy_rotation.py @@ -41,6 +41,21 @@ str(exc) — см. комментарий в app.api.v1.admin.rotate_proxy_ip (~ httpx-исключения несут полный request URL/детали, поэтому наружу — только нейтральный reason, полные детали — в лог с exc_info=True. +⛔ Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка +НЕОДНОРОДНА — часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip- +ссылки (`https://changeip.mobileproxy.space/?proxy_key=<секрет mobileproxy>`, +см. app.api.v1.admin._provider_rotate_url / avito_proxy_rotate_url), не ASocks. +Без явной проверки хоста наш `Authorization: Bearer ` ушёл бы +на ЧУЖОЙ провайдер (mobileproxy) — плюс сам GET/POST по их changeip, вероятно, +реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как +успех ASocks. rotate_proxy ПЕРЕД любым HTTP-вызовом проверяет +urlparse(rotate_url).hostname == ALLOWED_ROTATE_HOST (https-only) — несовпадение +это ОТКАЗ (ok=False, нейтральный reason), а НЕ попытка безголового запроса без +Authorization: смысл ручной ротации — конкретный провайдер (ASocks), молчаливый +вызов чужой ручки без авторизации — это сюрприз оператору (он думает "ASocks +ротировал", а фактически задел mobileproxy), которого проще не допустить, чем +потом объяснять админу расхождение счётчиков. + psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ @@ -49,6 +64,7 @@ from __future__ import annotations import logging from dataclasses import dataclass from typing import Any +from urllib.parse import urlparse import httpx from sqlalchemy import text @@ -59,6 +75,7 @@ from app.core.config import settings logger = logging.getLogger(__name__) __all__ = [ + "ALLOWED_ROTATE_HOST", "DAILY_ROTATION_LIMIT", "RotationResult", "rotate_proxy", @@ -70,6 +87,21 @@ DAILY_ROTATION_LIMIT = 3 # Таймаут POST refresh-ip. Пункт задачи требует "~30с". _ROTATE_TIMEOUT_S = 30.0 +# Единственный хост, на который разрешено уходить с ASOCKS_API_TOKEN в заголовке +# (см. "⛔ Хост-пиннинг" в docstring модуля). scrape_proxies.rotate_url может +# нести ЧУЖИЕ changeip-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова. +ALLOWED_ROTATE_HOST = "api.asocks.com" + + +def _is_allowed_rotate_url(url: str) -> bool: + """https-only + hostname точно ALLOWED_ROTATE_HOST (регистронезависимо — + urlparse().hostname уже лоуеркейзит). Не бросает исключений на кривом url.""" + try: + parsed = urlparse(url) + except ValueError: + return False + return parsed.scheme == "https" and parsed.hostname == ALLOWED_ROTATE_HOST + @dataclass class RotationResult: @@ -194,11 +226,14 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult: Порядок: 1. proxy_id не найден в scrape_proxies → ok=False, reason нейтральный. 2. rotate_url пусто → ok=False, "ротация не поддерживается" (НЕ ошибка). - 3. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False, + 3. rotate_url хост != ALLOWED_ROTATE_HOST (https://api.asocks.com) → ok=False + ДО HTTP-вызова — токен не должен уйти на чужой провайдер (mobileproxy + changeip и т.п. в этой же колонке пула, см. "⛔ Хост-пиннинг" в модуле). + 4. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False, внятный отказ, ничего не ломается. - 4. Суточный лимит (см. _quota_used_today) исчерпан → ok=False, отказ БЕЗ + 5. Суточный лимит (см. _quota_used_today) исчерпан → ok=False, отказ БЕЗ обращения к API. - 5. POST rotate_url с Authorization: Bearer , timeout ~30с. + 6. POST rotate_url с Authorization: Bearer , timeout ~30с. - Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL (НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True. - 401 → громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается @@ -229,6 +264,24 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult: ok=False, reason="rotation not supported for this proxy (no rotate_url configured)" ) + if not _is_allowed_rotate_url(rotate_url): + # scrape_proxies.rotate_url колонка неоднородна (другие строки пула несут + # mobileproxy changeip-ссылки с ИХ секретом) — отправлять наш + # Authorization: Bearer на непроверенный хост нельзя. + # Логируем ТОЛЬКО hostname (не полный url — на других провайдерах он + # несёт их собственный секрет в query-string, тот же класс утечки, что + # и в rotate_proxy_ip, см. модуль docstring). + logger.warning( + "proxy_rotation: proxy_id=%d rotate_url host=%r is not the allowed ASocks host " + "(%s) — refusing before any HTTP call to avoid leaking the token to it", + proxy_id, + urlparse(rotate_url).hostname, + ALLOWED_ROTATE_HOST, + ) + return RotationResult( + ok=False, reason="rotation not supported for this proxy (unexpected rotate host)" + ) + token = settings.asocks_api_token if not token: logger.warning( @@ -254,15 +307,21 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult: try: async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client: resp = await client.post(rotate_url, headers={"Authorization": f"Bearer {token}"}) - except Exception: + except Exception as exc: # Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера, # значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести - # служебные детали соединения) — только exc_info=True в лог. + # служебные детали соединения) — только exc_info=True в лог. type(exc).__name__ + # секрета не несёт (это имя класса — ConnectError/ReadTimeout/…) и в note + # ПОЛЕЗЕН оператору: отличить "не дозвонились" от "дозвонились, зависли". logger.warning( "proxy_rotation: request failed (no response) proxy_id=%d", proxy_id, exc_info=True ) _record_attempt( - db, proxy_id, success=False, http_status=None, note="request failed (no response)" + db, + proxy_id, + success=False, + http_status=None, + note=f"request failed: {type(exc).__name__}", ) return RotationResult( ok=False, diff --git a/tradein-mvp/backend/tests/services/test_proxy_rotation.py b/tradein-mvp/backend/tests/services/test_proxy_rotation.py index a51adbd4..a5762f9a 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_rotation.py +++ b/tradein-mvp/backend/tests/services/test_proxy_rotation.py @@ -10,8 +10,12 @@ FakeSession эмулирует scrape_proxies (одна строка) + scrape_p - Успешная ротация пишет запись в scrape_proxy_rotations (success=True). - 401 → logger.error (громкий отказ) + sentry_sdk.capture_message (мониторинг), аудит-запись пишется, но НЕ считается против суточного лимита. - - Токен не появляется ни в RotationResult.reason, ни в note аудит-записи — + - Токен не появляется ни в RotationResult.reason, ни в note аудит-записи, ни в + тексте log-сообщений (caplog.getMessage()), ни в тексте, ушедшем в Sentry — ни в одном из сценариев (сеть-ошибка, 401, provider 5xx, success). + - rotate_url на ЧУЖОМ хосте (не ALLOWED_ROTATE_HOST) → отказ ДО HTTP-вызова — + scrape_proxies.rotate_url колонка неоднородна (несёт и mobileproxy changeip- + ссылки), наш ASOCKS_API_TOKEN не должен уйти на них (security review PR #2611). """ from __future__ import annotations @@ -167,6 +171,10 @@ def _no_http_allowed(): _DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip" +# (name, rotate_url, response=(status, json_body)|None, exception|None) — ровно один +# из response/exception задан, либо оба None (локальный отказ, HTTP не идёт). +_LogScenario = tuple[str, str | None, tuple[int, dict[str, Any] | None] | None, Exception | None] + def _proxy_row(rotate_url: str | None = _DEFAULT_ROTATE_URL) -> dict[str, Any]: return {"id": 1, "rotate_url": rotate_url} @@ -202,6 +210,64 @@ async def test_no_rotate_url_is_not_an_error(monkeypatch: pytest.MonkeyPatch) -> assert db.rotations == [] # ничего не писалось — попытки не было +# ── host pinning (security review PR #2611) ───────────────────────────────── +# +# scrape_proxies.rotate_url колонка неоднородна: прод сейчас несёт mobileproxy +# changeip-ссылки (id 3/4/5) БОК О БОК с ASocks-ссылками (id 1/9/10/11, миграция +# 199). Без host-пиннинга наш Authorization: Bearer ушёл бы +# на чужой провайдер. + + +async def test_rotate_url_on_foreign_host_refused_before_http_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + foreign_url = "https://changeip.mobileproxy.space/?proxy_key=mobileproxy-own-secret" + db = FakeSession(_proxy_row(rotate_url=foreign_url)) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert result.reason is not None + # _no_http_allowed() would have raised AssertionError from within rotate_proxy + # if the code had tried an HTTP call (i.e. sent our token) — reaching this + # line means it refused first. Belt-and-suspenders: no audit row either + # (this is a local rejection, same as no-rotate_url/no-token/limit). + assert db.rotations == [] + assert SECRET_TOKEN not in result.reason + + +async def test_allowed_host_case_insensitive_still_proceeds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Хост сверяется без учёта регистра (urlparse().hostname лоуеркейзит) — тот + же ALLOWED_ROTATE_HOST в другом регистре ДОЛЖЕН проходить, иначе пиннинг + превратился бы в ложный отказ на легитимном rotate_url.""" + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + fake_client, calls = _fake_async_client(response=(200, {"ip": "1.2.3.4"}), exception=None) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + + db = FakeSession(_proxy_row(rotate_url="https://API.ASOCKS.COM/unlimited-proxy/1/refresh-ip")) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is True + assert len(calls) == 1 + + +async def test_allowed_host_over_plain_http_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + """http:// (не https://) на тот же хост — отказ (защита от даунгрейда + транспорта, которым Authorization ушёл бы в открытом виде).""" + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(_proxy_row(rotate_url="http://api.asocks.com/unlimited-proxy/1/refresh-ip")) + result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + assert result.ok is False + assert db.rotations == [] + + # ── missing token → neutral refusal, no crash ─────────────────────────────── @@ -354,6 +420,9 @@ async def test_token_never_appears_in_reason_on_network_error( # сетевая ошибка не подтверждает, что провайдер обработал попытку → квота не тратится assert db.rotations[0]["http_status"] is None assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type] + # exception class name (не секрет) в note — оператор отличит "не дозвонились" + # (ConnectError) от "дозвонились, зависли" (ReadTimeout). + assert "ConnectError" in (db.rotations[0]["note"] or "") async def test_token_never_appears_on_provider_error_status( @@ -375,6 +444,58 @@ async def test_token_never_appears_on_provider_error_status( assert proxy_rotation._quota_used_today(db, 1) == 1 # type: ignore[arg-type] +async def test_token_never_appears_in_log_messages_or_sentry_text( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Расширенное leak-покрытие (security review PR #2611): предыдущие тесты + проверяли только reason/note. Здесь — текст, реально уходящий в logging и в + Sentry (не exc_info-traceback, который по дизайну МОЖЕТ нести детали + исключения — см. модуль docstring; это осознанно разрешённое место). + caplog.records[i].getMessage() возвращает форматированный msg %% args, БЕЗ + exc_text — то есть эта проверка ловит именно "секрет попал в аргумент + logger.*()", а не в traceback. + """ + sentry_texts: list[str] = [] + monkeypatch.setattr( + "sentry_sdk.capture_message", + lambda msg, level=None: sentry_texts.append(msg), + ) + monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN) + + scenarios: list[_LogScenario] = [ + ("success", _DEFAULT_ROTATE_URL, (200, {"ip": "1.1.1.1"}), None), + ("401", _DEFAULT_ROTATE_URL, (401, {"message": "Unauthenticated"}), None), + ("provider_500", _DEFAULT_ROTATE_URL, (500, {"message": "err"}), None), + ( + "network_error", + _DEFAULT_ROTATE_URL, + None, + httpx.ConnectError(f"boom token={SECRET_TOKEN}"), + ), + ("foreign_host", "https://changeip.mobileproxy.space/?proxy_key=x", None, None), + ] + + for name, rotate_url, response, exception in scenarios: + if response is not None or exception is not None: + fake_client, _ = _fake_async_client(response=response, exception=exception) + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client) + else: + monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed()) + + db = FakeSession(_proxy_row(rotate_url=rotate_url)) + with caplog.at_level(logging.DEBUG): + caplog.clear() + await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] + + for record in caplog.records: + assert ( + SECRET_TOKEN not in record.getMessage() + ), f"scenario={name}: token leaked into log message args" + + assert sentry_texts, "expected at least one Sentry capture (401 scenario)" + assert all(SECRET_TOKEN not in text for text in sentry_texts) + + # ── proxy not found ────────────────────────────────────────────────────────── -- 2.45.3