gendesign/tradein-mvp/backend/app/core/anon_session.py
bot-backend eef10f406f
All checks were successful
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m12s
feat(mera/b2c): анти-абуз для анонимного трафика — этап 2 из 8
Блокер номер один перед открытием эндпоинта оценки наружу: анонимный запрос
означал БЕЗЛИМИТ. В сервисе квот отсутствие имени пользователя трактовалось
как unlimited во всех функциях, с комментарием «dev без Caddy, fail-open».
Единственной защитой был общий лимит 300 запросов в минуту на IP — это
анти-флуд для дешёвых запросов, а не бизнес-лимит для пайплайна на десятки
секунд.

1. Fail-open больше не по умолчанию. Вызывающая сторона передаёт пустую
   личность только при явно включённом флаге разработки (по умолчанию выкл).

2. Анонимная личность — подписанная кука с HMAC-SHA256, отдельным каналом
   от X-Authenticated-User. Тот заголовок ставит Caddy и валидирует внутренним
   секретом; смешивать схемы нельзя, это сломало бы модель безопасности.
   Ключ подписи из окружения; если не задан — эфемерный на процесс, с
   предупреждением в лог.

3. Анонимная квота на паре «сессия + IP», переиспользует существующую таблицу
   и тот же атомарный инкремент под WHERE used < lim (защита от гонки #747).
   Честно закомментировано: смена IP или чистка куки обходит лимит — задача
   поднять стоимость злоупотребления, а не сделать его невозможным.

4. Отдельный жёсткий лимит частоты на оценку, проверяется ДО квоты.
   Переиспользован готовый SlidingWindowLimiter. Redis намеренно не задействован:
   прод работает одним воркером, состояние теряется только при рестарте, а
   основная защита — месячная квота в Postgres. Компромисс задокументирован.

5. Потолок времени ответа. Вызов Avito IMV шёл БЕЗ бюджета, в отличие от всех
   соседних — единственный источник неограниченного времени. Обёрнут.
   Суммарный худший случай: было ~186 с (36 с ограниченных плюс IMV без
   границы ~150 с), стало 56 с.

Тесты: 2754 passed. Два существующих теста обновлены под изменившееся
поведение fail-open — это ожидаемое изменение, не регрессия.
2026-07-28 15:21:03 +03:00

111 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Подписанный анонимный session-cookie — B2C anti-abuse этап 2 (#b2c-antiabuse-2).
Продукт открывается для анонимных пользователей (без Caddy basic_auth /
X-Authenticated-User). Чтобы применить анонимную квоту оценок (см.
app.services.account_quota), нужен стабильный, но НЕ подделываемый идентификатор
анонимной сессии — отдельный от X-Authenticated-User (этот заголовок ставит
Caddy и валидируется внутренним секретом; смешивать схемы идентичности нельзя,
это сломало бы модель безопасности #2213).
Дизайн: session_id — случайный токен (secrets.token_urlsafe), подписанный
HMAC-SHA256 вместе с expiry в cookie-значении `mera_anon_sid`. Сервер верифицирует
подпись на каждом запросе; невалидная/просроченная/отсутствующая cookie → минтится
новая сессия (клиент просто теряет накопленную анонимную квоту — это ОЖИДАЕМО и
безопасно: анонимная квота и так обходится чисткой cookie/сменой IP, задача
поднять стоимость злоупотребления, а не сделать его невозможным).
Подпись предотвращает две вещи: (1) клиент не может подставить ЧУЖОЙ session_id
(например, скопированный у другого пользователя) без знания секрета — иначе он
мог бы попытаться "унаследовать" чужую квоту или испортить чужой учёт; (2) любая
порча/усечение cookie-значения детектируется явно (HMAC mismatch), а не тихо
парсится как валиден мусорный session_id.
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import secrets
import time
from fastapi import Request, Response
from app.core.config import settings
logger = logging.getLogger(__name__)
ANON_COOKIE_NAME = "mera_anon_sid"
# 180 дней — грубо совпадает с "долгоживущий браузер, не чистящий cookies";
# не критично для безопасности (лимит всё равно per-период_month), только для
# TTL самой cookie в браузере/HMAC-подписи.
ANON_SESSION_TTL_S = 180 * 24 * 3600
# Пусто (default) → эфемерный per-process секрет: криптографически стойкий
# (клиент не знает его и не может подделать сессию), но сессии не переживают
# рестарт процесса и не общие между воркерами (см. anon_session_secret в
# core/config.py). Вычисляется один раз при импорте модуля.
if settings.anon_session_secret:
_SESSION_SECRET: bytes = settings.anon_session_secret.encode("utf-8")
else:
_SESSION_SECRET = secrets.token_bytes(32)
logger.warning(
"ANON_SESSION_SECRET не задан — используется process-local эфемерный "
"секрет анонимной сессии (сессии сбрасываются при рестарте / не общие "
"между воркерами). Задай ANON_SESSION_SECRET в .env.runtime для "
"стабильности между рестартами."
)
def _sign(session_id: str, expires_at: int) -> str:
msg = f"{session_id}.{expires_at}".encode()
return hmac.new(_SESSION_SECRET, msg, hashlib.sha256).hexdigest()
def _encode(session_id: str, expires_at: int) -> str:
return f"{session_id}.{expires_at}.{_sign(session_id, expires_at)}"
def _decode_and_verify(cookie_value: str) -> str | None:
"""Возвращает session_id если cookie валидна (подпись + не просрочена), иначе None."""
parts = cookie_value.split(".", 2)
if len(parts) != 3:
return None
session_id, expires_at_raw, sig = parts
try:
expires_at = int(expires_at_raw)
except ValueError:
return None
if time.time() > expires_at:
return None
expected = _sign(session_id, expires_at)
if not hmac.compare_digest(sig, expected):
return None
return session_id
def get_or_create_anon_session_id(request: Request, response: Response) -> str:
"""Возвращает стабильный анонимный session_id, выставляя cookie при необходимости.
Читает `mera_anon_sid` из запроса; если отсутствует, повреждена, просрочена
или не проходит проверку подписи — минтит НОВУЮ сессию и выставляет свежую
cookie на *response* (httponly, samesite=lax; secure вне dev-окружения).
"""
raw = request.cookies.get(ANON_COOKIE_NAME)
if raw:
verified = _decode_and_verify(raw)
if verified is not None:
return verified
logger.info("anon_session: invalid/tampered/expired cookie — minting new session")
session_id = secrets.token_urlsafe(16)
expires_at = int(time.time()) + ANON_SESSION_TTL_S
response.set_cookie(
ANON_COOKIE_NAME,
_encode(session_id, expires_at),
max_age=ANON_SESSION_TTL_S,
httponly=True,
samesite="lax",
secure=settings.environment != "dev",
)
return session_id