diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py
index 22fb6c17..ac67695b 100644
--- a/tradein-mvp/backend/app/api/v1/trade_in.py
+++ b/tradein-mvp/backend/app/api/v1/trade_in.py
@@ -342,11 +342,13 @@ def get_estimate(
def estimate_pdf(
estimate_id: UUID,
db: Annotated[Session, Depends(get_db)],
- brand: str | None = None,
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> Response:
"""Скачать 4-страничный PDF-отчёт для оценки trade-in.
+ Бренд PDF определяется по владельцу оценки (estimate.created_by → brand slug),
+ а НЕ из ?brand= query param (#7 brand-spoofing fix).
+
Возвращает application/pdf attachment.
404 — оценка не найдена.
410 — оценка просрочена (TTL 24ч).
@@ -430,9 +432,13 @@ def estimate_pdf(
"has_balcony": row.has_balcony,
}
+ from app.core.auth import get_brand_for_user as _brand_for_user
from app.services.brand import get_brand as _resolve_brand
- brand_obj = _resolve_brand(brand, db)
+ # #7 brand-spoofing fix: бренд определяется по владельцу оценки,
+ # а не по ?brand= query param (который был удалён из сигнатуры).
+ owner_brand_slug = _brand_for_user(row.created_by) if row.created_by else None
+ brand_obj = _resolve_brand(owner_brand_slug, db)
pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj)
filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf"
logger.info(
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 bdc26863..ae4fee39 100644
--- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
+++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
@@ -19,6 +19,7 @@ import datetime as dt
import html as _html
import io
import logging
+import re
from urllib.parse import urlparse
from uuid import UUID
@@ -95,6 +96,85 @@ def _safe_url(url: str | None, *, cdn: bool = False) -> str | None:
return url
+# ── Logo URL и color-sanitation (#13 SSRF / CSS-injection guard) ─────────────
+# Допустимые домены для логотипов брендов (white-label).
+_ALLOWED_LOGO_CDN: frozenset[str] = frozenset(
+ {
+ "gk-praktika.ru",
+ "www.gk-praktika.ru",
+ "prinzip.ru",
+ "www.prinzip.ru",
+ "cdn.gk-praktika.ru",
+ }
+)
+
+_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
+
+
+def _safe_logo_url(url: str | None) -> str | None:
+ """Валидирует logo_url бренда: только https:// + allowlist домен + не data:/file:.
+
+ data: URI допускаются для инлайн-логотипов (нет сетевого запроса).
+ Всё остальное отбрасывается. Если передан file:// — отбрасывается.
+ """
+ if not url:
+ return None
+ try:
+ p = urlparse(url)
+ except Exception:
+ return None
+ if p.scheme == "data":
+ # data: URI безопасны — нет SSRF, только инлайн
+ return url
+ if p.scheme != "https":
+ # file://, http://, javascript: — все отбрасываем
+ return None
+ if p.netloc not in _ALLOWED_LOGO_CDN:
+ logger.warning("logo_url domain not in allowlist: %s", p.netloc)
+ return None
+ return url
+
+
+def _safe_color(color: str | None, fallback: str) -> str:
+ """Принимает только #RRGGBB hex. Всё остальное → fallback.
+
+ Защита от CSS-injection через primary_color/accent_color бренда
+ (WeasyPrint вставляет значение напрямую в style=).
+ """
+ if color and _COLOR_RE.match(color):
+ return color
+ return fallback
+
+
+# ── WeasyPrint custom url_fetcher (SSRF guard) ───────────────────────────────
+
+
+def _make_safe_url_fetcher(): # type: ignore[no-untyped-def]
+ """Возвращает url_fetcher для WeasyPrint, блокирующий file:// и нелоговые схемы.
+
+ Разрешены: https:// (логотипы + шрифты CDN) и data: (инлайн SVG/PNG).
+ Блокированы: file://, http://, ftp:// и любые другие.
+ """
+
+ def fetcher(url: str, timeout: int = 10, ssl_context=None): # type: ignore[no-untyped-def]
+ # Импорт отложен до момента первого реального fetch, чтобы тесты могли
+ # подменять weasyprint.urls через sys.modules без ModuleNotFoundError.
+ from weasyprint.urls import default_url_fetcher
+
+ try:
+ p = urlparse(url)
+ except Exception:
+ raise ValueError(f"Invalid URL in PDF resource: {url!r}") from None
+ if p.scheme in ("data", "https"):
+ return default_url_fetcher(url, timeout=timeout, ssl_context=ssl_context)
+ raise ValueError(
+ f"PDF resource scheme '{p.scheme}' is not allowed (only data: and https:). "
+ f"URL: {url!r}"
+ )
+
+ return fetcher
+
+
# ── Source pseudo-logos (текстовые pill-badges как у Брусники с логотипами) ──
_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
"avito": ("#00aaff", "#fff"), # Avito brand blue (упрощённо)
@@ -433,13 +513,28 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
sub_label="Диапазон цен по фактическим сделкам",
)
+ logo_url = _safe_logo_url(brand.logo_url)
+ logo_html = (
+ f""
+ if logo_url
+ else f""
+ f"{_html.escape(brand.name).upper()}"
+ )
+ disclaimer_html = ""
+ if brand.pdf_disclaimer:
+ disclaimer_html = (
+ f"
" + f"{_html.escape(brand.pdf_disclaimer)}
" + ) + return f"""+ № {report_num} · {today.strftime("%d.%m.%Y")} · {address} +
++ По данному объекту не найдено достаточного количества аналогов + для формирования надёжной рыночной оценки. + Пожалуйста, уточните параметры или обратитесь к специалисту. +
+