Merge pull request 'fix(tradein/security): admin.py — generic detail + error-id вместо сырого текста исключений (#2234)' (#2261) from fix/tradein-admin-exception-leak into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m25s
Deploy Trade-In / build-backend (push) Successful in 1m54s
Deploy Trade-In / deploy (push) Successful in 1m32s

This commit is contained in:
lekss361 2026-07-03 06:27:22 +00:00
commit 5f6db8dbb7
2 changed files with 71 additions and 16 deletions

View file

@ -11,6 +11,7 @@ import logging
import time import time
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from uuid import uuid4
import httpx import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query 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") 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"] _ALL_SOURCES = ["avito", "cian", "yandex"]
@ -547,14 +564,16 @@ async def scrape_avito_house(
try: try:
enrichment = await fetch_house_catalog(house_url) enrichment = await fetch_house_catalog(house_url)
except Exception as e: except Exception as e:
logger.exception("avito-house: fetch failed for %s", house_url) raise _safe_http_error(
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e 502, "fetch failed", f"avito-house: fetch failed for {house_url}"
) from e
try: try:
counters = save_house_catalog_enrichment(db, enrichment) counters = save_house_catalog_enrichment(db, enrichment)
except Exception as e: except Exception as e:
logger.exception("avito-house: save failed for %s", house_url) raise _safe_http_error(
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e 500, "save failed", f"avito-house: save failed for {house_url}"
) from e
logger.info("avito-house ok: %s%s", house_url, counters) logger.info("avito-house ok: %s%s", house_url, counters)
return {"ok": True, "house_url": house_url, "counters": counters} return {"ok": True, "house_url": house_url, "counters": counters}
@ -576,14 +595,16 @@ async def scrape_avito_detail(
try: try:
enrichment = await fetch_detail(item_url) enrichment = await fetch_detail(item_url)
except Exception as e: except Exception as e:
logger.exception("avito-detail: fetch failed for %s", item_url) raise _safe_http_error(
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e 502, "fetch failed", f"avito-detail: fetch failed for {item_url}"
) from e
try: try:
updated = save_detail_enrichment(db, enrichment) updated = save_detail_enrichment(db, enrichment)
except Exception as e: except Exception as e:
logger.exception("avito-detail: save failed for %s", item_url) raise _safe_http_error(
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e 500, "save failed", f"avito-detail: save failed for {item_url}"
) from e
if not updated: if not updated:
# Listing с этим source_id ещё не существует в БД — был bypass через # Listing с этим source_id ещё не существует в БД — был bypass через
@ -625,8 +646,9 @@ async def scrape_avito_detail_backfill(
try: try:
result = await run_avito_detail_backfill(db, run_id=run_id, params=params) result = await run_avito_detail_backfill(db, run_id=run_id, params=params)
except Exception as e: except Exception as e:
logger.exception("avito-detail-backfill: run_id=%d crashed", run_id) raise _safe_http_error(
raise HTTPException(status_code=502, detail=f"backfill failed: {e}") from e 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()) 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()} return {"ok": True, "run_id": run_id, "counters": result.to_dict()}
@ -666,17 +688,22 @@ async def scrape_avito_imv(
has_loggia=has_loggia, has_loggia=has_loggia,
) )
except IMVAddressNotFoundError as e: except IMVAddressNotFoundError as e:
raise HTTPException(status_code=404, detail=f"IMV address not found: {e}") from e # Ожидаемое клиентское условие (адрес не в базе 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: except Exception as e:
logger.exception("avito-imv: fetch failed for %s", address) raise _safe_http_error(502, "fetch failed", f"avito-imv: fetch failed for {address}") from e
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
try: try:
eval_id = save_imv_evaluation(db, result) eval_id = save_imv_evaluation(db, result)
history_saved = save_imv_placement_history(db, eval_id, result.placement_history) history_saved = save_imv_placement_history(db, eval_id, result.placement_history)
except Exception as e: except Exception as e:
logger.exception("avito-imv: save failed for %s", address) raise _safe_http_error(500, "save failed", f"avito-imv: save failed for {address}") from e
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
logger.info( logger.info(
"avito-imv ok: addr=%s recommended=%d evaluation_id=%d history=%d", "avito-imv ok: addr=%s recommended=%d evaluation_id=%d history=%d",

View file

@ -14,7 +14,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
@ -564,3 +564,31 @@ def test_data_quality_pct_in_range(client: TestClient) -> None:
assert ( assert (
0.0 <= pct <= 100.0 0.0 <= pct <= 100.0
), f"source={src['source']} field={field_name} pct={pct} вне [0,100]" ), 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")