From c92b574e598b1e9e061b53a00c4233b3250b0e1f Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 23 May 2026 12:24:11 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20security=20hotfix=20=E2=80=94?= =?UTF-8?q?=20config=20default=20+=20empty=20estimate=20persist=20+=20PDF?= =?UTF-8?q?=20URL=20allowlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-3: убрать hardcoded DATABASE_URL default из Settings (fail-fast если env не задан). C-4: _empty_estimate теперь делает INSERT в БД — GET /estimate/{id} не возвращает 404. C-6: _safe_url allowlist против javascript:/data: в source_url; CDN allowlist для photo_url. --- tradein-mvp/backend/app/core/config.py | 7 +- tradein-mvp/backend/app/services/estimator.py | 72 +++++++++++++++++-- .../app/services/exporters/trade_in_pdf.py | 46 +++++++++++- 3 files changed, 115 insertions(+), 10 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 3b5ffbb7..dcf7b42c 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -6,14 +6,17 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") - database_url: str = "postgresql+psycopg://tradein:tradein@postgres:5432/tradein" + # required — задаётся через env DATABASE_URL. Нет дефолта: fail-fast при старте + # если переменная не задана (C-3 security audit). + database_url: str cors_origins: list[str] = ["http://localhost", "http://localhost:3000", "http://localhost:8080"] environment: str = "dev" # Geocoder yandex_geocoder_key: str | None = None # 25K req/day free после регистрации yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend) - contact_email: str = "erginrajpopxbe@outlook.com" # для User-Agent в Nominatim (Nominatim Usage Policy) + # для User-Agent в Nominatim (Nominatim Usage Policy) + contact_email: str = "erginrajpopxbe@outlook.com" # Public URL — для QR-кода в PDF, shareable links, etc. public_url: str = "http://127.0.0.1:8080" diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index f4f81cdb..7dfd2ff3 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -23,7 +23,7 @@ import json import logging from datetime import UTC, datetime, timedelta from typing import Any -from uuid import UUID, uuid4 +from uuid import uuid4 from sqlalchemy import text from sqlalchemy.orm import Session @@ -83,7 +83,7 @@ async def estimate_quality( if geo is None: # Без координат не можем искать через PostGIS. Возвращаем low confidence. logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address) - return _empty_estimate(payload, reason="address_not_geocoded") + return _empty_estimate(payload, db, reason="address_not_geocoded") # 2. #392: обогащаем год / тип дома из картографии (OSM Overpass), если # пользователь их не указал — это улучшает house-match аналогов (#6). @@ -562,19 +562,79 @@ def _deal_to_analog(row: dict[str, Any]) -> AnalogLot: ) -def _empty_estimate(payload: TradeInEstimateInput, *, reason: str) -> AggregatedEstimate: - """Fallback когда нет данных для оценки.""" +def _empty_estimate( + payload: TradeInEstimateInput, db: Session, *, reason: str +) -> AggregatedEstimate: + """Fallback когда нет данных для оценки. + + Сохраняет запись в БД (confidence='low', пустые analogs/deals), чтобы GET /estimate/{id} + не возвращал 404. C-4 security audit. + """ + estimate_id = uuid4() now = datetime.now(tz=UTC) + expires_at = now + timedelta(hours=24) + + db.execute( + text( + """ + INSERT INTO trade_in_estimates ( + id, address, + area_m2, rooms, floor, total_floors, + year_built, house_type, repair_state, has_balcony, + ownership_type, has_mortgage, client_name, client_phone, + median_price, range_low, range_high, median_price_per_m2, + confidence, confidence_explanation, n_analogs, + analogs, actual_deals, + sources_used, + expires_at + ) VALUES ( + CAST(:id AS uuid), :address, + :area, :rooms, :floor, :total_floors, + :year_built, :house_type, :repair_state, :has_balcony, + :ownership_type, :has_mortgage, :client_name, :client_phone, + 0, 0, 0, 0, + 'low', :explanation, 0, + '[]'::jsonb, '[]'::jsonb, + '[]'::jsonb, + :expires_at + ) + """ + ), + { + "id": str(estimate_id), + "address": payload.address, + "area": payload.area_m2, + "rooms": payload.rooms, + "floor": payload.floor, + "total_floors": payload.total_floors, + "year_built": payload.year_built, + "house_type": payload.house_type, + "repair_state": payload.repair_state, + "has_balcony": payload.has_balcony, + "ownership_type": payload.ownership_type, + "has_mortgage": payload.has_mortgage, + "client_name": payload.client_name, + "client_phone": payload.client_phone, + "explanation": reason, + "expires_at": expires_at, + }, + ) + db.commit() + logger.info( + "empty_estimate: id=%s reason=%s addr=%s", estimate_id, reason, payload.address[:60] + ) + return AggregatedEstimate( - estimate_id=uuid4(), + estimate_id=estimate_id, median_price_rub=0, range_low_rub=0, range_high_rub=0, median_price_per_m2=0, confidence="low", + confidence_explanation=reason, n_analogs=0, period_months=DEALS_PERIOD_MONTHS, analogs=[], actual_deals=[], - expires_at=now + timedelta(hours=24), + expires_at=expires_at, ) diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index d68f68c9..bec1f4b7 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -17,6 +17,7 @@ import datetime as dt import html as _html import io import logging +from urllib.parse import urlparse from uuid import UUID import segno @@ -45,6 +46,46 @@ def _bar_svg_data_url(q_low: float = 0.20, q_high: float = 0.80) -> str: logger = logging.getLogger(__name__) +# ── URL allowlist (C-6 security audit) ───────────────────────────────────── +# Только доверенные домены-источники объявлений попадают в в PDF. +# Защита от javascript: / data: инъекций через source_url объявлений. +_ALLOWED_URL_DOMAINS: frozenset[str] = frozenset({ + "www.avito.ru", "avito.ru", "m.avito.ru", + "www.cian.ru", "cian.ru", + "realty.yandex.ru", + "n1.ru", "ekaterinburg.n1.ru", +}) + +# CDN-домены для изображений аналогов (photo_url). +_ALLOWED_PHOTO_CDN: frozenset[str] = frozenset({ + "images.avito.st", + "cdn-p.cian.site", + "avatars.mds.yandex.net", + "n1ru.cdn-cw.com", +}) + + +def _safe_url(url: str | None, *, cdn: bool = False) -> str | None: + """Проверяет URL по allowlist схемы и домена. + + cdn=False — allowlist для source_url объявлений (ссылки на листинги). + cdn=True — allowlist для photo_url (CDN изображений). + Возвращает None если URL не прошёл проверку — embed/ссылка пропускается. + """ + if not url: + return None + try: + p = urlparse(url) + except Exception: + return None + if p.scheme != "https": + return None + allowed = _ALLOWED_PHOTO_CDN if cdn else _ALLOWED_URL_DOMAINS + if p.netloc not in allowed: + return None + return url + + # ── Source pseudo-logos (текстовые pill-badges как у Брусники с логотипами) ── _SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = { "avito": ("#00aaff", "#fff"), # Avito brand blue (упрощённо) @@ -484,8 +525,9 @@ def _examples_rows(lots: list[AnalogLot]) -> str: rows = [] for lot in lots: addr = _html.escape(lot.address) - if lot.source_url: - addr_cell = f"{addr} ↗" + safe = _safe_url(lot.source_url) + if safe: + addr_cell = f"{addr} ↗" else: addr_cell = addr rows.append( -- 2.45.3