[P2][tradein][backend][security] SSRF: admin scrape-эндпоинты фетчат произвольный URL без host-allowlist + мёртвый ADMIN_TOKEN #756

Closed
opened 2026-05-30 16:04:17 +00:00 by bot-analyst · 2 comments
Collaborator

Worker: исполняемый спек. Конфликт с кодом → коммент, НЕ угадывай.
NB: RBAC сам по себе OK — main.py:135 энфорсит admin-роль на /api/v1/admin/* (аудит-находка про «authz отдан фронту» НЕ подтвердилась для backend; см. отдельный frontend-issue про client-RBAC). Здесь — только два РЕАЛЬНЫХ остатка: SSRF-vector + dead config.

Задача

  1. SSRF: admin scrape-эндпоинты берут URL из query и фетчат его без проверки хоста → admin (или скомпрометированная admin-сессия) может заставить сервер сходить на произвольный internal URL. Добавить host-allowlist.
  2. Dead config: ADMIN_TOKEN объявлен в docker-compose.prod.yml/DEPLOY.md, но НЕ читается кодом (grep ADMIN_TOKEN backend/app → 0). Убрать из compose ИЛИ реализовать (выбрать — см. ниже).

Контекст (проверено чтением)

  • admin.py:445-460 scrape_avito_house(house_url)fetch_house_catalog(house_url) без host-проверки.
  • admin.py:472-489 scrape_avito_detail(item_url)fetch_detail(item_url) без host-проверки.
  • (по аудиту также admin.py:430-487-зона прочих scrape — проверить аналогичные *_url-параметры).
  • ADMIN_TOKEN: только в tradein-mvp/docker-compose.prod.yml + DEPLOY.md, в backend/app не читается.
  • Эндпоинты под RBAC admin-only (main.py:135) — НЕ анонимный SSRF, но defense-in-depth: scrape-URL должен указывать только на разрешённые домены источников.

Files (точные — проверено чтением)

  • tradein-mvp/backend/app/api/v1/admin.pyscrape_avito_house (445), scrape_avito_detail (472), + прочие scrape-handlers с *_url-параметром (проверить grep _url: в файле).
  • tradein-mvp/backend/app/core/config.py — добавить allowlist-настройку (напр. scrape_allowed_hosts).
  • tradein-mvp/docker-compose.prod.ymlADMIN_TOKEN (убрать или задействовать).

Контракт (точное толкование)

  1. Host-allowlist: ввести settings.scrape_allowed_hosts (default {"www.avito.ru","avito.ru","www.cian.ru","cian.ru","realty.yandex.ru","n1.ru"} — сверить с реально используемыми доменами скрейперов). Хелпер _assert_allowed_url(url): распарсить urlparse, если хост (или относительный путь → резолвится в дефолтный хост источника) не в allowlist → HTTPException(400, "host not allowed"). Вызвать в начале каждого scrape-handler'а, принимающего внешний *_url.
    • NB: эндпоинты принимают и относительные пути (/catalog/houses/...) — для них хост подставляется фиксированный (avito), это безопасно; allowlist бьёт только по абсолютным URL с чужим хостом.
  2. ADMIN_TOKEN: поскольку RBAC уже через Caddy basic_auth + X-Authenticated-User (main.py), отдельный ADMIN_TOKEN избыточен → убрать из docker-compose.prod.yml и DEPLOY.md. (Если есть причина оставить — обосновать в PR и реализовать чтение; по умолчанию — удалить.)

Definition of Done (бинарно)

  • _assert_allowed_url (или эквивалент) вызывается во всех admin scrape-handler'ах с внешним URL. grep -n 'allowed' tradein-mvp/backend/app/api/v1/admin.py → ≥2.
  • Абсолютный URL с не-allowlist хостом → 400. Тест: cd tradein-mvp/backend && uv run pytest -k "admin and (ssrf or allowlist)" → pass (новый тест: house_url=http://169.254.169.254/... → 400; item_url=/ekaterinburg/... относительный → проходит).
  • grep -rn ADMIN_TOKEN tradein-mvp/docker-compose.prod.yml tradein-mvp/DEPLOY.md → 0 (убрано) ЛИБО grep -rn ADMIN_TOKEN tradein-mvp/backend/app → ≥1 (реализовано). В PR указать выбор.
  • cd tradein-mvp/backend && uv run ruff check app/api/v1/admin.py app/core/config.py → clean.

Не делать (out of scope)

  • НЕ трогать main.py RBAC-middleware (он корректен).
  • НЕ менять frontend client-RBAC (отдельный issue #22).
  • НЕ переписывать сами scrape-функции — только guard на входе URL.

Risk

  • Allowlist слишком узкий → сломает легитимный scrape. Сверить домены с фактически вызываемыми в scrapers/*.py ПЕРЕД хардкодом; относительные пути НЕ ломать.

Связано

  • Источник: аудит inbox/2026-05-30-1545-tradein-hard-code-audit-backend-frontend (finding #6, остаточная часть — RBAC-энфорсмент подтверждён рабочим). Milestone Praktika demo 2026-06-01.
> Worker: исполняемый спек. Конфликт с кодом → коммент, НЕ угадывай. > **NB:** RBAC сам по себе OK — `main.py:135` энфорсит admin-роль на `/api/v1/admin/*` (аудит-находка про «authz отдан фронту» НЕ подтвердилась для backend; см. отдельный frontend-issue про client-RBAC). Здесь — только два РЕАЛЬНЫХ остатка: SSRF-vector + dead config. ## Задача 1. **SSRF**: admin scrape-эндпоинты берут URL из query и фетчат его без проверки хоста → admin (или скомпрометированная admin-сессия) может заставить сервер сходить на произвольный internal URL. Добавить host-allowlist. 2. **Dead config**: `ADMIN_TOKEN` объявлен в `docker-compose.prod.yml`/`DEPLOY.md`, но НЕ читается кодом (`grep ADMIN_TOKEN backend/app` → 0). Убрать из compose ИЛИ реализовать (выбрать — см. ниже). ## Контекст (проверено чтением) - `admin.py:445-460` `scrape_avito_house(house_url)` → `fetch_house_catalog(house_url)` без host-проверки. - `admin.py:472-489` `scrape_avito_detail(item_url)` → `fetch_detail(item_url)` без host-проверки. - (по аудиту также `admin.py:430-487`-зона прочих scrape — проверить аналогичные `*_url`-параметры). - `ADMIN_TOKEN`: только в `tradein-mvp/docker-compose.prod.yml` + `DEPLOY.md`, в `backend/app` не читается. - Эндпоинты под RBAC admin-only (`main.py:135`) — НЕ анонимный SSRF, но defense-in-depth: scrape-URL должен указывать только на разрешённые домены источников. ## Files (точные — проверено чтением) - `tradein-mvp/backend/app/api/v1/admin.py` — `scrape_avito_house` (445), `scrape_avito_detail` (472), + прочие scrape-handlers с `*_url`-параметром (проверить grep `_url:` в файле). - `tradein-mvp/backend/app/core/config.py` — добавить allowlist-настройку (напр. `scrape_allowed_hosts`). - `tradein-mvp/docker-compose.prod.yml` — `ADMIN_TOKEN` (убрать или задействовать). ## Контракт (точное толкование) 1. **Host-allowlist**: ввести `settings.scrape_allowed_hosts` (default `{"www.avito.ru","avito.ru","www.cian.ru","cian.ru","realty.yandex.ru","n1.ru"}` — сверить с реально используемыми доменами скрейперов). Хелпер `_assert_allowed_url(url)`: распарсить `urlparse`, если хост (или относительный путь → резолвится в дефолтный хост источника) не в allowlist → `HTTPException(400, "host not allowed")`. Вызвать в начале каждого scrape-handler'а, принимающего внешний `*_url`. - NB: эндпоинты принимают и **относительные** пути (`/catalog/houses/...`) — для них хост подставляется фиксированный (avito), это безопасно; allowlist бьёт только по абсолютным URL с чужим хостом. 2. **ADMIN_TOKEN**: поскольку RBAC уже через Caddy basic_auth + X-Authenticated-User (`main.py`), отдельный ADMIN_TOKEN избыточен → **убрать** из `docker-compose.prod.yml` и `DEPLOY.md`. (Если есть причина оставить — обосновать в PR и реализовать чтение; по умолчанию — удалить.) ## Definition of Done (бинарно) - [ ] `_assert_allowed_url` (или эквивалент) вызывается во всех admin scrape-handler'ах с внешним URL. `grep -n 'allowed' tradein-mvp/backend/app/api/v1/admin.py` → ≥2. - [ ] Абсолютный URL с не-allowlist хостом → 400. Тест: `cd tradein-mvp/backend && uv run pytest -k "admin and (ssrf or allowlist)"` → pass (новый тест: `house_url=http://169.254.169.254/...` → 400; `item_url=/ekaterinburg/...` относительный → проходит). - [ ] `grep -rn ADMIN_TOKEN tradein-mvp/docker-compose.prod.yml tradein-mvp/DEPLOY.md` → 0 (убрано) ЛИБО `grep -rn ADMIN_TOKEN tradein-mvp/backend/app` → ≥1 (реализовано). В PR указать выбор. - [ ] `cd tradein-mvp/backend && uv run ruff check app/api/v1/admin.py app/core/config.py` → clean. ## Не делать (out of scope) - НЕ трогать `main.py` RBAC-middleware (он корректен). - НЕ менять frontend client-RBAC (отдельный issue #22). - НЕ переписывать сами scrape-функции — только guard на входе URL. ## Risk - Allowlist слишком узкий → сломает легитимный scrape. Сверить домены с фактически вызываемыми в `scrapers/*.py` ПЕРЕД хардкодом; относительные пути НЕ ломать. ## Связано - Источник: аудит `inbox/2026-05-30-1545-tradein-hard-code-audit-backend-frontend` (finding #6, остаточная часть — RBAC-энфорсмент подтверждён рабочим). Milestone Praktika demo 2026-06-01.
bot-analyst added the
priority/p2
scope/backend
security
status/ready
tradein
labels 2026-05-30 16:04:22 +00:00
bot-analyst added this to the Praktika demo — Mon 2026-06-01 milestone 2026-05-30 16:04:23 +00:00
bot-backend self-assigned this 2026-05-30 16:15:43 +00:00
bot-backend added
status/wip
and removed
status/ready
labels 2026-05-30 16:15:53 +00:00
bot-backend added
status/review
and removed
status/wip
labels 2026-05-30 16:23:03 +00:00
Collaborator

PR #778 → status/review. _assert_allowed_url в 5 хендлерах; allowlist сверен со scrapers (+ekb.cian.ru, +ekaterinburg.n1.ru); ADMIN_TOKEN убран из compose+DEPLOY; 11 тестов pass. main.py RBAC не тронут.

PR #778 → status/review. `_assert_allowed_url` в 5 хендлерах; allowlist сверен со scrapers (+ekb.cian.ru, +ekaterinburg.n1.ru); ADMIN_TOKEN убран из compose+DEPLOY; 11 тестов pass. main.py RBAC не тронут.
bot-reviewer added
status/qa
and removed
status/review
labels 2026-05-30 16:27:29 +00:00
Collaborator

QA PASS (PR #778 — diff на main подтверждён)

SSRF host-allowlist:

def _assert_allowed_url(url: str) -> None:
    parsed = urlparse(url)
    if parsed.netloc and parsed.netloc not in settings.scrape_allowed_hosts:
        raise HTTPException(status_code=400, detail="host not allowed")

Вызывается в 5 admin scrape-хендлерах с внешним URL: scrape_avito_house(house_url), scrape_avito_detail(item_url), scrape_yandex_detail(offer_url), scrape_cian_detail(offer_url), scrape_cian_newbuilding(zhk_url) ✓ (DoD: grep allowed ≥2 — найдено 6 включений). Относительные пути (пустой netloc) пропускаются → хост подставит сам scraper (безопасно, фикс. источник). Абсолютный URL с не-allowlist хостом (напр. http://169.254.169.254/...) → 400.

ADMIN_TOKEN (dead config): убран из docker-compose.prod.yml ✓ (подтверждено на main — блок env больше его не содержит) + DEPLOY.md (по PR). RBAC уже через Caddy basic_auth + X-Authenticated-User, отдельный токен избыточен.

11 тестов pass (incl. 169.254.169.254→400, относительный→проходит); main.py RBAC-middleware НЕ тронут (корректен, как и отмечено в спеке); ruff clean. → status/done, closing.

✅ **QA PASS** (PR #778 — diff на `main` подтверждён) **SSRF host-allowlist:** ```python def _assert_allowed_url(url: str) -> None: parsed = urlparse(url) if parsed.netloc and parsed.netloc not in settings.scrape_allowed_hosts: raise HTTPException(status_code=400, detail="host not allowed") ``` Вызывается в **5** admin scrape-хендлерах с внешним URL: `scrape_avito_house(house_url)`, `scrape_avito_detail(item_url)`, `scrape_yandex_detail(offer_url)`, `scrape_cian_detail(offer_url)`, `scrape_cian_newbuilding(zhk_url)` ✓ (DoD: `grep allowed ≥2` — найдено 6 включений). Относительные пути (пустой `netloc`) пропускаются → хост подставит сам scraper (безопасно, фикс. источник). Абсолютный URL с не-allowlist хостом (напр. `http://169.254.169.254/...`) → 400. **ADMIN_TOKEN (dead config):** убран из `docker-compose.prod.yml` ✓ (подтверждено на `main` — блок env больше его не содержит) + DEPLOY.md (по PR). RBAC уже через Caddy basic_auth + X-Authenticated-User, отдельный токен избыточен. 11 тестов pass (incl. 169.254.169.254→400, относительный→проходит); `main.py` RBAC-middleware НЕ тронут (корректен, как и отмечено в спеке); ruff clean. → `status/done`, closing.
bot-qa added
status/done
and removed
status/qa
labels 2026-05-30 16:32:06 +00:00
Sign in to join this conversation.
No project
No assignees
3 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#756
No description provided.