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
Блокер номер один перед открытием эндпоинта оценки наружу: анонимный запрос означал БЕЗЛИМИТ. В сервисе квот отсутствие имени пользователя трактовалось как 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 — это ожидаемое изменение, не регрессия.
111 lines
5.8 KiB
Python
111 lines
5.8 KiB
Python
"""Подписанный анонимный 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
|