fix(tradein/proxy): pin ASocks rotate_url host — не слать токен на чужой прокси (#2600)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m46s

scrape_proxies.rotate_url колонка неоднородна: прод несёт и mobileproxy
changeip-ссылки (id 3/4/5), и ASocks-ссылки (id 1/9/10/11). Без явной проверки
хоста Authorization: Bearer <ASOCKS_API_TOKEN> ушёл бы на чужой провайдер —
security review PR #2611. Добавлен ALLOWED_ROTATE_HOST-пиннинг (https-only,
хост == api.asocks.com) ДО HTTP-вызова; несовпадение — отказ, не безголовый
запрос без Authorization (смысл ручной ротации — конкретный провайдер).

Заодно: класс исключения (не секрет) в note сетевой ошибки — отличить
ConnectError от ReadTimeout; расширено leak-покрытие на текст log/Sentry
сообщений (не только reason/note).
This commit is contained in:
bot-backend 2026-08-01 22:12:52 +03:00
parent b1563b86cb
commit bed2b7bca9
2 changed files with 187 additions and 7 deletions

View file

@ -41,6 +41,21 @@ str(exc) — см. комментарий в app.api.v1.admin.rotate_proxy_ip (~
httpx-исключения несут полный request URL/детали, поэтому наружу только httpx-исключения несут полный request URL/детали, поэтому наружу только
нейтральный reason, полные детали в лог с exc_info=True. нейтральный 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 <ASOCKS_API_TOKEN>` ушёл бы
на ЧУЖОЙ провайдер (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. psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
""" """
@ -49,6 +64,7 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from urllib.parse import urlparse
import httpx import httpx
from sqlalchemy import text from sqlalchemy import text
@ -59,6 +75,7 @@ from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__all__ = [ __all__ = [
"ALLOWED_ROTATE_HOST",
"DAILY_ROTATION_LIMIT", "DAILY_ROTATION_LIMIT",
"RotationResult", "RotationResult",
"rotate_proxy", "rotate_proxy",
@ -70,6 +87,21 @@ DAILY_ROTATION_LIMIT = 3
# Таймаут POST refresh-ip. Пункт задачи требует "~30с". # Таймаут POST refresh-ip. Пункт задачи требует "~30с".
_ROTATE_TIMEOUT_S = 30.0 _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 @dataclass
class RotationResult: class RotationResult:
@ -194,11 +226,14 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
Порядок: Порядок:
1. proxy_id не найден в scrape_proxies ok=False, reason нейтральный. 1. proxy_id не найден в scrape_proxies ok=False, reason нейтральный.
2. rotate_url пусто ok=False, "ротация не поддерживается" (НЕ ошибка). 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. обращения к API.
5. POST rotate_url с Authorization: Bearer <token>, timeout ~30с. 6. POST rotate_url с Authorization: Bearer <token>, timeout ~30с.
- Сетевая ошибка (нет ответа) ok=False, аудит-запись http_status=NULL - Сетевая ошибка (нет ответа) ok=False, аудит-запись http_status=NULL
(НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True. (НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True.
- 401 громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается - 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)" 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 <ASOCKS_API_TOKEN> на непроверенный хост нельзя.
# Логируем ТОЛЬКО 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 token = settings.asocks_api_token
if not token: if not token:
logger.warning( logger.warning(
@ -254,15 +307,21 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
try: try:
async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client: async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client:
resp = await client.post(rotate_url, headers={"Authorization": f"Bearer {token}"}) resp = await client.post(rotate_url, headers={"Authorization": f"Bearer {token}"})
except Exception: except Exception as exc:
# Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера, # Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера,
# значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести # значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести
# служебные детали соединения) — только exc_info=True в лог. # служебные детали соединения) — только exc_info=True в лог. type(exc).__name__
# секрета не несёт (это имя класса — ConnectError/ReadTimeout/…) и в note
# ПОЛЕЗЕН оператору: отличить "не дозвонились" от "дозвонились, зависли".
logger.warning( logger.warning(
"proxy_rotation: request failed (no response) proxy_id=%d", proxy_id, exc_info=True "proxy_rotation: request failed (no response) proxy_id=%d", proxy_id, exc_info=True
) )
_record_attempt( _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( return RotationResult(
ok=False, ok=False,

View file

@ -10,8 +10,12 @@ FakeSession эмулирует scrape_proxies (одна строка) + scrape_p
- Успешная ротация пишет запись в scrape_proxy_rotations (success=True). - Успешная ротация пишет запись в scrape_proxy_rotations (success=True).
- 401 logger.error (громкий отказ) + sentry_sdk.capture_message (мониторинг), - 401 logger.error (громкий отказ) + sentry_sdk.capture_message (мониторинг),
аудит-запись пишется, но НЕ считается против суточного лимита. аудит-запись пишется, но НЕ считается против суточного лимита.
- Токен не появляется ни в RotationResult.reason, ни в note аудит-записи - Токен не появляется ни в RotationResult.reason, ни в note аудит-записи, ни в
тексте log-сообщений (caplog.getMessage()), ни в тексте, ушедшем в Sentry
ни в одном из сценариев (сеть-ошибка, 401, provider 5xx, success). ни в одном из сценариев (сеть-ошибка, 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 from __future__ import annotations
@ -167,6 +171,10 @@ def _no_http_allowed():
_DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip" _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]: def _proxy_row(rotate_url: str | None = _DEFAULT_ROTATE_URL) -> dict[str, Any]:
return {"id": 1, "rotate_url": rotate_url} 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 == [] # ничего не писалось — попытки не было 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 <ASOCKS_API_TOKEN> ушёл бы
# на чужой провайдер.
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 ─────────────────────────────── # ── 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 db.rotations[0]["http_status"] is None
assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type] 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( 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] 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 ────────────────────────────────────────────────────────── # ── proxy not found ──────────────────────────────────────────────────────────