From c65a73ca9a48e15bc7d4f9aa94bb61cf622eeeff Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 09:16:34 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein/security):=20admin.py=20?= =?UTF-8?q?=E2=80=94=20generic=20detail=20+=20error-id=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20=D1=81=D1=8B=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B9=20(#2234)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/admin.py | 52 +++++++++++++------ .../backend/tests/test_scraper_admin_apis.py | 30 ++++++++++- 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 8b243d1b..c6197edb 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -11,6 +11,7 @@ import logging import time from typing import Annotated, Any, Literal from urllib.parse import urlparse, urlunparse +from uuid import uuid4 import httpx from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query @@ -68,6 +69,22 @@ def _assert_allowed_url(url: str) -> None: raise HTTPException(status_code=400, detail="host not allowed") +def _safe_http_error(status_code: int, public_detail: str, log_context: str) -> HTTPException: + """Defense-in-depth: не отдаём текст исключения в HTTP-ответ. + + Текст psycopg/httpx-исключений может содержать DSN-фрагменты, внутренние + хосты, пути. Логируем полный exception с коротким error-id через + ``logger.exception`` (уйдёт в GlitchTip), а в HTTP отдаём generic detail + с тем же error-id для корреляции. + + ВАЖНО: вызывать только внутри ``except``-блока — ``logger.exception`` + полагается на активный exc_info. + """ + error_id = uuid4().hex[:8] + logger.exception("%s (error-id=%s)", log_context, error_id) + return HTTPException(status_code=status_code, detail=f"{public_detail} (error-id: {error_id})") + + _ALL_SOURCES = ["avito", "cian", "yandex"] @@ -547,14 +564,16 @@ async def scrape_avito_house( try: enrichment = await fetch_house_catalog(house_url) except Exception as e: - logger.exception("avito-house: fetch failed for %s", house_url) - raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e + raise _safe_http_error( + 502, "fetch failed", f"avito-house: fetch failed for {house_url}" + ) from e try: counters = save_house_catalog_enrichment(db, enrichment) except Exception as e: - logger.exception("avito-house: save failed for %s", house_url) - raise HTTPException(status_code=500, detail=f"save failed: {e}") from e + raise _safe_http_error( + 500, "save failed", f"avito-house: save failed for {house_url}" + ) from e logger.info("avito-house ok: %s → %s", house_url, counters) return {"ok": True, "house_url": house_url, "counters": counters} @@ -576,14 +595,16 @@ async def scrape_avito_detail( try: enrichment = await fetch_detail(item_url) except Exception as e: - logger.exception("avito-detail: fetch failed for %s", item_url) - raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e + raise _safe_http_error( + 502, "fetch failed", f"avito-detail: fetch failed for {item_url}" + ) from e try: updated = save_detail_enrichment(db, enrichment) except Exception as e: - logger.exception("avito-detail: save failed for %s", item_url) - raise HTTPException(status_code=500, detail=f"save failed: {e}") from e + raise _safe_http_error( + 500, "save failed", f"avito-detail: save failed for {item_url}" + ) from e if not updated: # Listing с этим source_id ещё не существует в БД — был bypass через @@ -625,8 +646,9 @@ async def scrape_avito_detail_backfill( try: result = await run_avito_detail_backfill(db, run_id=run_id, params=params) except Exception as e: - logger.exception("avito-detail-backfill: run_id=%d crashed", run_id) - raise HTTPException(status_code=502, detail=f"backfill failed: {e}") from e + raise _safe_http_error( + 502, "backfill failed", f"avito-detail-backfill: run_id={run_id} crashed" + ) from e logger.info("avito-detail-backfill ok: run_id=%d %s", run_id, result.to_dict()) return {"ok": True, "run_id": run_id, "counters": result.to_dict()} @@ -666,17 +688,17 @@ async def scrape_avito_imv( has_loggia=has_loggia, ) except IMVAddressNotFoundError as e: - raise HTTPException(status_code=404, detail=f"IMV address not found: {e}") from e + raise _safe_http_error( + 404, "IMV address not found", f"avito-imv: address not found for {address}" + ) from e except Exception as e: - logger.exception("avito-imv: fetch failed for %s", address) - raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e + raise _safe_http_error(502, "fetch failed", f"avito-imv: fetch failed for {address}") from e try: eval_id = save_imv_evaluation(db, result) history_saved = save_imv_placement_history(db, eval_id, result.placement_history) except Exception as e: - logger.exception("avito-imv: save failed for %s", address) - raise HTTPException(status_code=500, detail=f"save failed: {e}") from e + raise _safe_http_error(500, "save failed", f"avito-imv: save failed for {address}") from e logger.info( "avito-imv ok: addr=%s recommended=%d evaluation_id=%d history=%d", diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index 497b1c46..4d1a4390 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -14,7 +14,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: from datetime import UTC, datetime from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import FastAPI @@ -564,3 +564,31 @@ def test_data_quality_pct_in_range(client: TestClient) -> None: assert ( 0.0 <= pct <= 100.0 ), f"source={src['source']} field={field_name} pct={pct} вне [0,100]" + + +# ── Security: текст исключения не утекает в HTTP-ответ (#2234) ──────────────── + + +def test_admin_error_detail_hides_exception_text(client: TestClient) -> None: + """Внутренняя зависимость кидает исключение с 'секретным' текстом → + + в теле ответа НЕТ секрета, есть generic detail + error-id; статус-код прежний. + Роут /scrape/avito-detail: fetch_detail падает с DSN-подобным сообщением. + """ + secret = "postgres://user:SECRETPASS@internal-host:5432/db" + with patch( + "app.api.v1.admin.fetch_detail", + new=AsyncMock(side_effect=Exception(secret)), + ): + r = client.post( + "/api/v1/admin/scrape/avito-detail", + params={"item_url": "/ekaterinburg/kvartiry/1-k_kvartira_100"}, + ) + + assert r.status_code == 502 + body = r.json() + detail = body["detail"] + assert "SECRETPASS" not in detail + assert secret not in detail + assert "error-id:" in detail + assert detail.startswith("fetch failed") From efa0bafa18d981dc712aa759416b3ccbf2b12d82 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 09:20:44 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(tradein/security):=20404=20IMV-=D0=B0?= =?UTF-8?q?=D0=B4=D1=80=D0=B5=D1=81=20=E2=80=94=20warning=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20traceback,=20=D0=BD=D0=B5=20exception-=D1=88=D1=83?= =?UTF-8?q?=D0=BC=20(#2234)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/api/v1/admin.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index c6197edb..ff45e927 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -688,8 +688,13 @@ async def scrape_avito_imv( has_loggia=has_loggia, ) except IMVAddressNotFoundError as e: - raise _safe_http_error( - 404, "IMV address not found", f"avito-imv: address not found for {address}" + # Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning + # без traceback, чтобы не шуметь exception-событиями в GlitchTip. Адрес — в лог, + # не в HTTP-ответ. + error_id = uuid4().hex[:8] + logger.warning("avito-imv: address not found for %s (error-id=%s)", address, error_id) + raise HTTPException( + status_code=404, detail=f"IMV address not found (error-id: {error_id})" ) from e except Exception as e: raise _safe_http_error(502, "fetch failed", f"avito-imv: fetch failed for {address}") from e