feat(tradein/proxy): ротация exit-IP ASocks по бану со счётчиком и громким отказом (#2600) #2611

Merged
lekss361 merged 2 commits from feat/tradein-asocks-rotation into main 2026-08-01 19:23:51 +00:00
Owner

Summary

  • Новый модуль app/services/proxy_rotation.py (rotate_proxy(db, proxy_id)): POST rotate_url из scrape_proxies с Authorization: Bearer <ASOCKS_API_TOKEN> (env, app/core/config.py), таймаут ~30с.
  • Суточный лимит 3 ротации/сутки на прокси, аудит-таблица scrape_proxy_rotations (миграция 198).
  • Миграция 199 проставляет rotate_url для 4 существующих ASocks-портов пула, сопоставление по host:port (суффикс url), НЕ по id.
  • Новый admin-эндпоинт POST /api/v1/admin/proxies/{proxy_id}/rotate — ручная ротация по proxy_id из пула (существующий /scraper/{source}/rotate-ip для env-прокси mobileproxy НЕ тронут).
  • Громкий отказ на 401 от провайдера: logger.error + sentry_sdk.capture_message(level="error") — токен протух, молчаливая остановка недопустима.
  • Токен никогда не попадает в reason/HTTP-ответ/аудит-запись — только в лог с exc_info=True (прецедент утечки — см. комментарий у rotate_proxy_ip в admin.py).

Какие попытки считаются против суточного лимита

quota-consuming := http_status IS NOT NULL AND http_status != 401 — засчитывается любой реальный ответ провайдера (успех 200 и его собственные 4xx/5xx), но НЕ 401 (auth-отсев ДО обращения к rotate-логике порта — провайдер буквально отвечает "Unauthenticated", не мог засчитать использование тому, кого не подтвердил) и НЕ сетевые ошибки (ответа вообще не было — нет подтверждения, что запрос дошёл до провайдера). Локальные отказы (нет rotate_url / нет токена / лимит уже исчерпан) до HTTP-вызова не доходят и в таблицу не пишутся вовсе.

Known limitation (issue #2600 п.5, явно)

Автоматический вызов по детекту бана НЕ реализован — сигнал бана до пула пока не доходит (страница-заглушка отдаёт 200, issue #2600 п.2, отдельная задача). Прошивать вызов в скраперы сейчас — риск на горячем пути. Следующий шаг: когда детект бана появится, дергать rotate_proxy из него.

Границы

  • app/services/proxy_pool.py, packages/scraper-kit/**, browser_fetcher.py, providers/_proxy.py — не тронуты (параллельный PR правит proxy_pool.py).

Test plan

  • ruff check — чисто (config.py, admin.py, proxy_rotation.py, test_proxy_rotation.py)
  • 10 новых тестов tests/services/test_proxy_rotation.py — все сценарии из задачи (no rotate_url, no token, 4-я попытка за сутки, success пишет историю, 401 громкий + мониторинг + исключён из лимита, токен не течёт в 4 сценариях, unknown proxy_id)
  • Falsification (сделано, не в PR): временно снёс sentry-alert / off-by-one в лимите / инъекция токена в reason — все 3 сломанных варианта поймал соответствующий тест, затем откат
  • Полный pytest в tradein-mvp/backend: 3021 passed, 9 skipped, 1 pre-existing fail (tests/test_search_api.py::test_search_cache_hit, 401 от RBAC, известен, не мой)

Refs #2600

## Summary - Новый модуль `app/services/proxy_rotation.py` (`rotate_proxy(db, proxy_id)`): POST `rotate_url` из `scrape_proxies` с `Authorization: Bearer <ASOCKS_API_TOKEN>` (env, `app/core/config.py`), таймаут ~30с. - Суточный лимит 3 ротации/сутки на прокси, аудит-таблица `scrape_proxy_rotations` (миграция 198). - Миграция 199 проставляет `rotate_url` для 4 существующих ASocks-портов пула, сопоставление по `host:port` (суффикс `url`), НЕ по `id`. - Новый admin-эндпоинт `POST /api/v1/admin/proxies/{proxy_id}/rotate` — ручная ротация по `proxy_id` из пула (существующий `/scraper/{source}/rotate-ip` для env-прокси mobileproxy НЕ тронут). - Громкий отказ на 401 от провайдера: `logger.error` + `sentry_sdk.capture_message(level="error")` — токен протух, молчаливая остановка недопустима. - Токен никогда не попадает в reason/HTTP-ответ/аудит-запись — только в лог с `exc_info=True` (прецедент утечки — см. комментарий у `rotate_proxy_ip` в `admin.py`). ## Какие попытки считаются против суточного лимита `quota-consuming := http_status IS NOT NULL AND http_status != 401` — засчитывается любой реальный ответ провайдера (успех 200 и его собственные 4xx/5xx), но НЕ 401 (auth-отсев ДО обращения к rotate-логике порта — провайдер буквально отвечает "Unauthenticated", не мог засчитать использование тому, кого не подтвердил) и НЕ сетевые ошибки (ответа вообще не было — нет подтверждения, что запрос дошёл до провайдера). Локальные отказы (нет `rotate_url` / нет токена / лимит уже исчерпан) до HTTP-вызова не доходят и в таблицу не пишутся вовсе. ## Known limitation (issue #2600 п.5, явно) Автоматический вызов по детекту бана НЕ реализован — сигнал бана до пула пока не доходит (страница-заглушка отдаёт 200, issue #2600 п.2, отдельная задача). Прошивать вызов в скраперы сейчас — риск на горячем пути. Следующий шаг: когда детект бана появится, дергать `rotate_proxy` из него. ## Границы - `app/services/proxy_pool.py`, `packages/scraper-kit/**`, `browser_fetcher.py`, `providers/_proxy.py` — не тронуты (параллельный PR правит proxy_pool.py). ## Test plan - [x] `ruff check` — чисто (config.py, admin.py, proxy_rotation.py, test_proxy_rotation.py) - [x] 10 новых тестов `tests/services/test_proxy_rotation.py` — все сценарии из задачи (no rotate_url, no token, 4-я попытка за сутки, success пишет историю, 401 громкий + мониторинг + исключён из лимита, токен не течёт в 4 сценариях, unknown proxy_id) - [x] Falsification (сделано, не в PR): временно снёс sentry-alert / off-by-one в лимите / инъекция токена в reason — все 3 сломанных варианта поймал соответствующий тест, затем откат - [x] Полный `pytest` в `tradein-mvp/backend`: 3021 passed, 9 skipped, 1 pre-existing fail (`tests/test_search_api.py::test_search_cache_hit`, 401 от RBAC, известен, не мой) Refs #2600
lekss361 added 1 commit 2026-08-01 18:42:57 +00:00
feat(tradein/proxy): ротация exit-IP ASocks по бану со счётчиком и громким отказом (#2600)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 2m40s
b1563b86cb
lekss361 reviewed 2026-08-01 19:00:48 +00:00
lekss361 left a comment
Author
Owner

Deep review (deep-code-reviewer) — CHANGES REQUESTED: 1 блокер

формально COMMENT: Forgejo не даёт reject собственный PR тем же аккаунтом. Merge заблокирован до фикса ниже.

Проверено на b1563b86 в отдельном worktree (PYTHONPATH форсирован на worktree, трассировка импорта подтверждена). Полный pytest: 3021 passed / 9 skipped / 1 failed (pre-existing test_search_cache_hit). Фальсификации утечки токена в reason/note и off-by-one в лимите — воспроизведены, тесты падают как заявлено.

BLOCK: ASocks Bearer-токен уходит на чужой хост

app/services/proxy_rotation.py:256 постит в rotate_url из БД, приклеивая Authorization: Bearer <ASOCKS_API_TOKEN>, БЕЗ проверки хоста. В проде прямо сейчас:

id 3/4/5  rotate_url = https://changeip.mobileproxy.space/?proxy_key=<...>
id 1/9/10/11  rotate_url = NULL -> после 199 будет api.asocks.com

Значит POST /api/v1/admin/proxies/3/rotate отправит наш ASocks-токен на changeip.mobileproxy.space. Это ровно те узлы (consecutive_fails=5, disabled), которые оператор в первую очередь и захочет «ротировать». Воспроизведено оффлайн на прод-подобной строке:

-> host          = changeip.mobileproxy.space
-> Authorization = Bearer asocks-session-token-DO-NOT-LEAK
result.ok = True, аудит-строка success=True http_status=200

Второй контур той же дыры: rotate_url задаётся через POST /proxies/bulk произвольной строкой — админ API-уровня получает примитив для эксфильтрации env-секрета, к которому иначе доступа не имеет.

Фикс — пин хоста перед отправкой заголовка:

from urllib.parse import urlparse

_ASOCKS_ROTATE_HOST = "api.asocks.com"
...
    host = (urlparse(rotate_url).hostname or "").lower()
    if host != _ASOCKS_ROTATE_HOST:
        logger.warning(
            "proxy_rotation: proxy_id=%d rotate_url host=%r is not ASocks - "
            "refusing to send ASocks credentials to a foreign host", proxy_id, host,
        )
        return RotationResult(
            ok=False,
            reason="rotation not supported for this proxy (unsupported provider)",
        )
  • тест: rotate_url='https://changeip.mobileproxy.space/?proxy_key=x' с _no_http_allowed() → HTTP-вызова быть не должно.

Утечка токена — остальные пути чисты

  • reason нового эндпоинта / текст исключений / note в scrape_proxy_rotations — токена нет, покрыто тестами (фальсифицировано 4 способами, каждый ловится).
  • exc_info=True — стандартный traceback не печатает locals; include_local_variables=False в app/main.py:85, так что и Sentry-события фреймов не тащат.
  • capture_message / logger.error в _alert_stale_token собраны из proxy_id, токена не касаются.
  • sentry_scrub.py токен НЕ покрывает (_URL_SECRET_QUERY_RE требует ?/& перед именем, форма Bearer <...> мимо) — сейчас неважно, потому что токен туда не попадает, но подпорки под ошибку нет.
  • rotate_url в БД токена не содержит (миграция 199 кладёт голый URL).

Медиум/лоу (не блокеры)

  • 🟡 Гонка: _quota_used_today → 30-секундный await → INSERT. Два параллельных вызова читают одинаковый used и оба ротируют. При единственном uvicorn-воркере это ловится легко (второй запрос обслуживается ровно во время await). Лечится не блокировкой строки через await (это заморозит луп), а резервированием: INSERT попытки ДО POST + UPDATE статусом после.
  • 🟡 POST /proxies/bulk делает rotate_url = EXCLUDED.rotate_url без COALESCE, а PATCH /proxies/{id} умеет только enabled. Повторная загрузка пула (именно так эти 4 строки и появились) молча обнулит rotate_url из 199, и восстановить его можно будет только руками — миграция уже в _schema_migrations.
  • 🟢 Таймаут: сейчас любая сетевая ошибка → http_status=NULL → квота не тратится. ReadTimeout (запрос ушёл, ответа нет) стоит считать против лимита, ConnectError — нет. Минимум — писать класс исключения в note (type(exc).__name__ безопасен), сейчас там константа.
  • 🟢 status >= 400 пропускает 3xx как успех (follow_redirects=False по умолчанию) и не смотрит на {"success": false} в 2xx-теле.
  • 🟢 SELECT'ы держат read-транзакцию через весь 30-секундный await — стоит db.rollback() после проверки квоты.
  • 🟢 Тесты не проверяют отсутствие токена в логах и в тексте capture_message (проверил: обе подмены проходят зелёными).

Что чисто

Миграции 198/199 идемпотентны, в BEGIN/COMMIT, номера свободны и на main, и на всех открытых ветках. WHERE из 199 прогнал read-only на проде — ровно id 1/9/10/11. RBAC: /api/v1/admin/* + _ADMIN_API_RE → admin-only. Без токена — внятный отказ, ничего не падает. Границы диффа соблюдены (proxy_pool.py / scraper-kit / browser_fetcher / providers/_proxy не тронуты), /scraper/{source}/rotate-ip не задет. ruff clean.

## Deep review (deep-code-reviewer) — CHANGES REQUESTED: 1 блокер > формально COMMENT: Forgejo не даёт reject собственный PR тем же аккаунтом. Merge заблокирован до фикса ниже. Проверено на b1563b86 в отдельном worktree (PYTHONPATH форсирован на worktree, трассировка импорта подтверждена). Полный pytest: 3021 passed / 9 skipped / 1 failed (pre-existing test_search_cache_hit). Фальсификации утечки токена в reason/note и off-by-one в лимите — воспроизведены, тесты падают как заявлено. ### BLOCK: ASocks Bearer-токен уходит на чужой хост `app/services/proxy_rotation.py:256` постит в `rotate_url` из БД, приклеивая `Authorization: Bearer <ASOCKS_API_TOKEN>`, БЕЗ проверки хоста. В проде прямо сейчас: ``` id 3/4/5 rotate_url = https://changeip.mobileproxy.space/?proxy_key=<...> id 1/9/10/11 rotate_url = NULL -> после 199 будет api.asocks.com ``` Значит `POST /api/v1/admin/proxies/3/rotate` отправит наш ASocks-токен на `changeip.mobileproxy.space`. Это ровно те узлы (consecutive_fails=5, disabled), которые оператор в первую очередь и захочет «ротировать». Воспроизведено оффлайн на прод-подобной строке: ``` -> host = changeip.mobileproxy.space -> Authorization = Bearer asocks-session-token-DO-NOT-LEAK result.ok = True, аудит-строка success=True http_status=200 ``` Второй контур той же дыры: `rotate_url` задаётся через `POST /proxies/bulk` произвольной строкой — админ API-уровня получает примитив для эксфильтрации env-секрета, к которому иначе доступа не имеет. Фикс — пин хоста перед отправкой заголовка: ```python from urllib.parse import urlparse _ASOCKS_ROTATE_HOST = "api.asocks.com" ... host = (urlparse(rotate_url).hostname or "").lower() if host != _ASOCKS_ROTATE_HOST: logger.warning( "proxy_rotation: proxy_id=%d rotate_url host=%r is not ASocks - " "refusing to send ASocks credentials to a foreign host", proxy_id, host, ) return RotationResult( ok=False, reason="rotation not supported for this proxy (unsupported provider)", ) ``` + тест: `rotate_url='https://changeip.mobileproxy.space/?proxy_key=x'` с `_no_http_allowed()` → HTTP-вызова быть не должно. ### Утечка токена — остальные пути чисты - reason нового эндпоинта / текст исключений / note в `scrape_proxy_rotations` — токена нет, покрыто тестами (фальсифицировано 4 способами, каждый ловится). - `exc_info=True` — стандартный traceback не печатает locals; `include_local_variables=False` в `app/main.py:85`, так что и Sentry-события фреймов не тащат. - `capture_message` / `logger.error` в `_alert_stale_token` собраны из `proxy_id`, токена не касаются. - `sentry_scrub.py` токен НЕ покрывает (`_URL_SECRET_QUERY_RE` требует `?`/`&` перед именем, форма `Bearer <...>` мимо) — сейчас неважно, потому что токен туда не попадает, но подпорки под ошибку нет. - `rotate_url` в БД токена не содержит (миграция 199 кладёт голый URL). ### Медиум/лоу (не блокеры) - 🟡 Гонка: `_quota_used_today` → 30-секундный `await` → INSERT. Два параллельных вызова читают одинаковый used и оба ротируют. При единственном uvicorn-воркере это ловится легко (второй запрос обслуживается ровно во время await). Лечится не блокировкой строки через await (это заморозит луп), а резервированием: INSERT попытки ДО POST + UPDATE статусом после. - 🟡 `POST /proxies/bulk` делает `rotate_url = EXCLUDED.rotate_url` без COALESCE, а `PATCH /proxies/{id}` умеет только `enabled`. Повторная загрузка пула (именно так эти 4 строки и появились) молча обнулит rotate_url из 199, и восстановить его можно будет только руками — миграция уже в `_schema_migrations`. - 🟢 Таймаут: сейчас любая сетевая ошибка → `http_status=NULL` → квота не тратится. `ReadTimeout` (запрос ушёл, ответа нет) стоит считать против лимита, `ConnectError` — нет. Минимум — писать класс исключения в `note` (`type(exc).__name__` безопасен), сейчас там константа. - 🟢 `status >= 400` пропускает 3xx как успех (`follow_redirects=False` по умолчанию) и не смотрит на `{"success": false}` в 2xx-теле. - 🟢 SELECT'ы держат read-транзакцию через весь 30-секундный await — стоит `db.rollback()` после проверки квоты. - 🟢 Тесты не проверяют отсутствие токена в логах и в тексте `capture_message` (проверил: обе подмены проходят зелёными). ### Что чисто Миграции 198/199 идемпотентны, в BEGIN/COMMIT, номера свободны и на main, и на всех открытых ветках. WHERE из 199 прогнал read-only на проде — ровно id 1/9/10/11. RBAC: `/api/v1/admin/*` + `_ADMIN_API_RE` → admin-only. Без токена — внятный отказ, ничего не падает. Границы диффа соблюдены (proxy_pool.py / scraper-kit / browser_fetcher / providers/_proxy не тронуты), `/scraper/{source}/rotate-ip` не задет. ruff clean.
bot-backend added 1 commit 2026-08-01 19:13:05 +00:00
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
bed2b7bca9
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).
Author
Owner

Security-блокер (токен на чужой хост) исправлен в bed2b7bc: rotate_url теперь пиннится к ALLOWED_ROTATE_HOST=api.asocks.com (https-only) ДО любого HTTP-вызова — несовпадение хоста = отказ, никакого безголового запроса без Authorization. Добавлены тесты (host на changeip.mobileproxy.space отклоняется без HTTP-вызова, case-insensitive host матчинг, http-даунгрейд отклоняется) + falsification (временно снёс пиннинг — тест поймал). Плюс из того же ревью: exception class name в note сетевой ошибки (ConnectError/ReadTimeout), расширено leak-покрытие на текст log/Sentry сообщений (не только reason/note) — тоже falsified. Полный pytest: 3025 passed, 9 skipped, 1 known pre-existing fail (test_search_cache_hit, не мой).

Security-блокер (токен на чужой хост) исправлен в bed2b7bc: rotate_url теперь пиннится к ALLOWED_ROTATE_HOST=api.asocks.com (https-only) ДО любого HTTP-вызова — несовпадение хоста = отказ, никакого безголового запроса без Authorization. Добавлены тесты (host на changeip.mobileproxy.space отклоняется без HTTP-вызова, case-insensitive host матчинг, http-даунгрейд отклоняется) + falsification (временно снёс пиннинг — тест поймал). Плюс из того же ревью: exception class name в note сетевой ошибки (ConnectError/ReadTimeout), расширено leak-покрытие на текст log/Sentry сообщений (не только reason/note) — тоже falsified. Полный pytest: 3025 passed, 9 skipped, 1 known pre-existing fail (test_search_cache_hit, не мой).
lekss361 merged commit 887df54908 into main 2026-08-01 19:23:51 +00:00
Author
Owner

Deep review дельты b1563b86..bed2b7bc — APPROVE, смержено

Блокер закрыт. merge_commit 887df549, деплой success 19:28:55Z, прод проверен.

Фальсификации (повторил сам, правкой файла)

подмена результат
if not _is_allowed_rotate_url(...)if False: FAILED test_rotate_url_on_foreign_host_refused_before_http_call — в аудите появляется строка note='request failed: AssertionError', т.е. post() реально вызвался. HTTP-вызов состоялся, а не просто reason другой.
токен в текст capture_message FAILED новый leak-тест (раньше проходил зелёным)
токен в формат-строку logger.warning FAILED новый leak-тест (раньше проходил зелёным)

Пробел, который я нашёл в прошлый раз, закрыт ровно там, где был.

Нит на будущее: комментарий в тесте «_no_http_allowed() would have raised AssertionError from within rotate_proxy» неточен — этот AssertionError глотает широкий except Exception вокруг httpx-вызова. Ловит на самом деле assert db.rotations == []. Хорошо, что он есть.

Обход пина — не нашёл

Прогнал ~30 враждебных URL, сравнивая вердикт _is_allowed_rotate_url() с тем, куда реально ушёл бы httpx 0.28.1 (httpx.URL(u).host). Расхождений нет: любой URL, проходящий пин, у httpx резолвится в https://api.asocks.com.

  • https://api.asocks.com@evil.tld/x → REFUSE (и urlparse, и httpx видят evil.tld) — классический userinfo-обход закрыт
  • backslash-форма https://evil.tld\@api.asocks.com/x → PASS, и httpx тоже api.asocks.com: httpx НЕ применяет WHATWG-правило «backslash = разделитель», расхождения нет
  • перевод строки и таб в хосте → urlsplit их вырезает (bpo-43882) → REFUSE; httpx на них кидает InvalidURL
  • api.asocks.com。evil.tld (U+FF61), аpi.asocks.com (кириллическая а), xn--pi-8ma.asocks.com → REFUSE; первый httpx резолвил бы в api.asocks.com.evil.tld, то есть промах в безопасную сторону
  • api.asocks.com.evil.tld, evil-api.asocks.com, sub.api.asocks.com, api.asocks.com. (trailing dot), [::1], 127.0.0.1, //host, javascript:, file: → REFUSE
  • порт и userinfo (:8443, user:pw@) проходят, но хост остаётся запиненным — не утечка
  • редирект: follow_redirects в httpx 0.28.1 по умолчанию False (проверил inspect.signature), автор его не включал; плюс httpx сам снимает Authorization при cross-origin редиректе — двойная защита
  • битые URL (https://[::1, нулевой байт, порт 99999) исключений не кидают

Порядок проверок

rotate_url пуст (259) → пин (267)token = settings.asocks_api_token (285) → лимит (293) → POST (309). На чужом хосте токен вообще не связывается в кадре стека — пин это самостоятельная защита, а не надстройка над include_local_variables=False.

Подтверждено живьём на проде (токен ещё не задан): proxies/3/rotate (mobileproxy) → "unexpected rotate host", proxies/9/rotate (ASocks) → "rotation not configured (missing API token)". Разные причины у двух строк = пин реально срабатывает раньше. В логе только hostname, proxy_key mobileproxy не засветился.

Регрессия

tests/services/test_proxy_rotation.py → 14 passed. Полный pytest → 3025 passed, 9 skipped, 1 failed (+4 = ровно новые тесты; фейл — известный pre-existing test_search_cache_hit). ruff check чисто. Дельта только proxy_rotation.py + его тест.

Прод после деплоя

  • _schema_migrations: 198 применена 19:28:41, 199 — 19:28:42
  • scrape_proxy_rotations создана, 0 строк, колонки и индекс (proxy_id, rotated_at) как в миграции
  • id 1/9/10/11 → https://api.asocks.com/unlimited-proxy/{223610715,225031312,231878029,231878030}/refresh-ip, токена нет
  • id 3/4/5 по-прежнему changeip.mobileproxy.space — ровно та неоднородность, которую теперь и стережёт пин
  • RBAC: X-Authenticated-User: user2 → 403 admin only

Остальные пункты прошлого ревью (гонка счётчика, ReadTimeout в квоту, rotate_url = EXCLUDED в /proxies/bulk, 3xx как успех, db.rollback() перед await) остаются follow-up'ами.

## Deep review дельты b1563b86..bed2b7bc — APPROVE, смержено Блокер закрыт. `merge_commit 887df549`, деплой `success` 19:28:55Z, прод проверен. ### Фальсификации (повторил сам, правкой файла) | подмена | результат | |---|---| | `if not _is_allowed_rotate_url(...)` → `if False:` | FAILED `test_rotate_url_on_foreign_host_refused_before_http_call` — в аудите появляется строка `note='request failed: AssertionError'`, т.е. `post()` реально вызвался. HTTP-вызов состоялся, а не просто reason другой. | | токен в текст `capture_message` | FAILED новый leak-тест (раньше проходил зелёным) | | токен в формат-строку `logger.warning` | FAILED новый leak-тест (раньше проходил зелёным) | Пробел, который я нашёл в прошлый раз, закрыт ровно там, где был. Нит на будущее: комментарий в тесте «_no_http_allowed() would have raised AssertionError from within rotate_proxy» неточен — этот AssertionError глотает широкий `except Exception` вокруг httpx-вызова. Ловит на самом деле `assert db.rotations == []`. Хорошо, что он есть. ### Обход пина — не нашёл Прогнал ~30 враждебных URL, сравнивая вердикт `_is_allowed_rotate_url()` с тем, куда реально ушёл бы httpx 0.28.1 (`httpx.URL(u).host`). Расхождений нет: **любой URL, проходящий пин, у httpx резолвится в `https://api.asocks.com`**. - `https://api.asocks.com@evil.tld/x` → REFUSE (и urlparse, и httpx видят `evil.tld`) — классический userinfo-обход закрыт - backslash-форма `https://evil.tld\@api.asocks.com/x` → PASS, и httpx тоже `api.asocks.com`: httpx НЕ применяет WHATWG-правило «backslash = разделитель», расхождения нет - перевод строки и таб в хосте → urlsplit их вырезает (bpo-43882) → REFUSE; httpx на них кидает InvalidURL - `api.asocks.com。evil.tld` (U+FF61), `аpi.asocks.com` (кириллическая а), `xn--pi-8ma.asocks.com` → REFUSE; первый httpx резолвил бы в `api.asocks.com.evil.tld`, то есть промах в безопасную сторону - `api.asocks.com.evil.tld`, `evil-api.asocks.com`, `sub.api.asocks.com`, `api.asocks.com.` (trailing dot), `[::1]`, `127.0.0.1`, `//host`, `javascript:`, `file:` → REFUSE - порт и userinfo (`:8443`, `user:pw@`) проходят, но хост остаётся запиненным — не утечка - редирект: `follow_redirects` в httpx 0.28.1 по умолчанию `False` (проверил `inspect.signature`), автор его не включал; плюс httpx сам снимает Authorization при cross-origin редиректе — двойная защита - битые URL (`https://[::1`, нулевой байт, порт 99999) исключений не кидают ### Порядок проверок `rotate_url` пуст (259) → **пин (267)** → `token = settings.asocks_api_token` (285) → лимит (293) → POST (309). На чужом хосте токен вообще не связывается в кадре стека — пин это самостоятельная защита, а не надстройка над `include_local_variables=False`. Подтверждено живьём на проде (токен ещё не задан): `proxies/3/rotate` (mobileproxy) → `"unexpected rotate host"`, `proxies/9/rotate` (ASocks) → `"rotation not configured (missing API token)"`. Разные причины у двух строк = пин реально срабатывает раньше. В логе только hostname, `proxy_key` mobileproxy не засветился. ### Регрессия `tests/services/test_proxy_rotation.py` → 14 passed. Полный pytest → `3025 passed, 9 skipped, 1 failed` (+4 = ровно новые тесты; фейл — известный pre-existing `test_search_cache_hit`). `ruff check` чисто. Дельта только `proxy_rotation.py` + его тест. ### Прод после деплоя - `_schema_migrations`: 198 применена 19:28:41, 199 — 19:28:42 - `scrape_proxy_rotations` создана, 0 строк, колонки и индекс `(proxy_id, rotated_at)` как в миграции - id 1/9/10/11 → `https://api.asocks.com/unlimited-proxy/{223610715,225031312,231878029,231878030}/refresh-ip`, токена нет - id 3/4/5 по-прежнему `changeip.mobileproxy.space` — ровно та неоднородность, которую теперь и стережёт пин - RBAC: `X-Authenticated-User: user2` → 403 `admin only` Остальные пункты прошлого ревью (гонка счётчика, `ReadTimeout` в квоту, `rotate_url = EXCLUDED` в `/proxies/bulk`, 3xx как успех, `db.rollback()` перед await) остаются follow-up'ами.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2611
No description provided.