fix(mera/public): Infinity/NaN во входе — 422, и бюджет считает такие запросы

Аудит живого сайта 30.08.2026: POST /api/public/mera/coverage с
{"lat":56.8,"lon":1e400,...} отвечал 500, и двенадцать таких запросов подряд
дали двенадцать пятисоток и ни одного 429. Две независимые поломки в одном
месте, обе воспроизведены локально до правки.

1. 500 вместо 422. json.loads принимает Infinity/-Infinity/NaN, а 1e400 даёт
   inf переполнением. Pydantic отбивает такое поле по границам и кладёт
   значение в input ошибки, а ответ об ошибке сериализуется
   json.dumps(allow_nan=False) и падает уже после входа в ответ. Ломается не
   поле, а сборка ответа об ошибке — одна на всё приложение, поэтому и
   обработчик один (app/core/http_errors.py), а не валидатор на lon.

2. Лимитер мимо. _enforce стоял первой строкой тела хендлера, а FastAPI
   валидирует тело позже зависимостей, но раньше тела — до проверки просто не
   доходило. Та же поправка места, что уже сделана сегодня у
   _require_public_estimate_enabled: перенос в dependencies. Сделано для всех
   ручек файла, не только coverage. У /estimate и /estimate/read флаг остаётся
   первой зависимостью — 429 на выключенной ручке подтверждал бы её
   существование.

Тесты двусторонние: снятие обработчика роняет 4 проверки 422, возврат лимитера
в тело роняет проверку бюджета (проверено).
This commit is contained in:
bot-backend 2026-08-30 00:11:56 +05:00
parent ba35c68eb2
commit df9dd52996
4 changed files with 190 additions and 21 deletions

View file

@ -70,6 +70,7 @@ import asyncio
import hashlib import hashlib
import logging import logging
import secrets import secrets
from collections.abc import Callable
from datetime import datetime from datetime import datetime
from typing import Annotated, Literal from typing import Annotated, Literal
@ -185,6 +186,33 @@ def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None
limiter.record(ip) limiter.record(ip)
def _budget(limiter: SlidingWindowLimiter, what: str) -> Callable[[Request], None]:
"""Per-IP бюджет КАК ЗАВИСИМОСТЬ, а не первой строкой тела.
Ровно та же поправка места, что уже сделана у
`_require_public_estimate_enabled` (см. его докстринг): FastAPI решает
зависимости РАНЬШЕ, чем валидирует тело, поэтому проверка в теле не
срабатывает на запросах, которые падают на разборе тела до неё просто не
доходит.
Для флага это стоило утечки схемы, для лимитера неограниченного потока
отказов: 12 запросов подряд с некорректным телом на `/coverage` дали
двенадцать ответов и ни одного 429 (замер 30.08.2026). Пока такой вход
ронял сериализацию 422 (чинится в app/core/http_errors.py), это был поток
500 с одного адреса; после починки поток 422, но всё так же мимо бюджета.
ЧЕГО ЭТА ЗАВИСИМОСТЬ НЕ ЛОВИТ: тело, которое не разбирается как JSON
вообще там `RequestValidationError` летит до решения зависимостей. Такой
запрос стоит один `json.loads` и остаётся под общим `RateLimitMiddleware`
(300/60с на IP); заводить ради него middleware поверх middleware смысла нет.
"""
def _dep(request: Request) -> None:
_enforce(limiter, request, what)
return _dep
class PublicSuggestInput(BaseModel): class PublicSuggestInput(BaseModel):
"""Вход публичного автокомплита. """Вход публичного автокомплита.
@ -231,9 +259,12 @@ def _query_with_city(query: str, city_hint: str | None) -> str:
return f"{city_hint}, {query}" return f"{city_hint}, {query}"
@router.post("/suggest", response_model=SuggestResponse) @router.post(
"/suggest",
response_model=SuggestResponse,
dependencies=[Depends(_budget(_suggest_limiter, "suggest"))],
)
async def public_suggest( async def public_suggest(
request: Request,
payload: PublicSuggestInput, payload: PublicSuggestInput,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> SuggestResponse: ) -> SuggestResponse:
@ -260,8 +291,6 @@ async def public_suggest(
десятка в публичном UI не показывается, а каждый лишний кандидат может десятка в публичном UI не показывается, а каждый лишний кандидат может
стоить внешнего вызова. стоить внешнего вызова.
""" """
_enforce(_suggest_limiter, request, "suggest")
# Суточный потолок — ПОСЛЕ per-IP: сначала отсекаем одиночного абузера его # Суточный потолок — ПОСЛЕ per-IP: сначала отсекаем одиночного абузера его
# собственным лимитом, и только оставшееся считаем в общий бюджет. # собственным лимитом, и только оставшееся считаем в общий бюджет.
daily_retry = _daily_suggest_limiter.retry_after(_GLOBAL_KEY) daily_retry = _daily_suggest_limiter.retry_after(_GLOBAL_KEY)
@ -302,9 +331,12 @@ async def public_suggest(
_suggest_slots.release() _suggest_slots.release()
@router.post("/coverage", response_model=CoverageProbeResponse) @router.post(
"/coverage",
response_model=CoverageProbeResponse,
dependencies=[Depends(_budget(_coverage_limiter, "coverage"))],
)
def public_coverage( def public_coverage(
request: Request,
payload: CoverageProbeInput, payload: CoverageProbeInput,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> CoverageProbeResponse: ) -> CoverageProbeResponse:
@ -318,7 +350,6 @@ def public_coverage(
Ответ не содержит ни одной цены (см. `CoverageProbeResponse`) бесплатный Ответ не содержит ни одной цены (см. `CoverageProbeResponse`) бесплатный
шаг доказывает наличие данных, цену продаёт платный. шаг доказывает наличие данных, цену продаёт платный.
""" """
_enforce(_coverage_limiter, request, "coverage")
return coverage_probe(payload=payload, db=db) return coverage_probe(payload=payload, db=db)
@ -351,9 +382,12 @@ _STATS_SQL = text("""
""") """)
@router.get("/stats", response_model=dict[str, LandingStat]) @router.get(
"/stats",
response_model=dict[str, LandingStat],
dependencies=[Depends(_budget(_stats_limiter, "stats"))],
)
def public_stats( def public_stats(
request: Request,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> dict[str, LandingStat]: ) -> dict[str, LandingStat]:
"""Витринные метрики лэндинга — готовый ночной срез (issue: числа по проду). """Витринные метрики лэндинга — готовый ночной срез (issue: числа по проду).
@ -374,8 +408,6 @@ def public_stats(
`value` числовое value_num, если оно есть; иначе value_text (для метрик, `value` числовое value_num, если оно есть; иначе value_text (для метрик,
у которых значение не число). Оба NULL отдаём null, а не выдуманный ноль. у которых значение не число). Оба NULL отдаём null, а не выдуманный ноль.
""" """
_enforce(_stats_limiter, request, "stats")
rows = db.execute(_STATS_SQL).fetchall() rows = db.execute(_STATS_SQL).fetchall()
return { return {
row.metric: LandingStat( row.metric: LandingStat(
@ -499,9 +531,12 @@ _SHOWCASE_SQL = text(
) )
@router.get("/showcase", response_model=ShowcaseResponse) @router.get(
"/showcase",
response_model=ShowcaseResponse,
dependencies=[Depends(_budget(_showcase_limiter, "showcase"))],
)
def public_showcase( def public_showcase(
request: Request,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> ShowcaseResponse: ) -> ShowcaseResponse:
"""Витрина лэндинга: реальные ДКП-сделки против прогноза МЕРЫ. """Витрина лэндинга: реальные ДКП-сделки против прогноза МЕРЫ.
@ -518,7 +553,6 @@ def public_showcase(
годных строк не поместилось и по какому правилу отсеяно остальное. Числа годных строк не поместилось и по какому правилу отсеяно остальное. Числа
считает пересчёт; без них витрина не имеет права подписаться честно. считает пересчёт; без них витрина не имеет права подписаться честно.
""" """
_enforce(_showcase_limiter, request, "showcase")
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first() run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()
if run is None: if run is None:
return ShowcaseResponse(computed_at=None, deals=[], stats=None) return ShowcaseResponse(computed_at=None, deals=[], stats=None)
@ -693,7 +727,13 @@ def _coverage_for(
@router.post( @router.post(
"/estimate", "/estimate",
response_model=PublicEstimateResult, response_model=PublicEstimateResult,
dependencies=[Depends(_require_public_estimate_enabled)], # Порядок несущий: флаг ПЕРВЫМ. Лимитер впереди него отвечал бы 429 на
# выключенной ручке, а несуществующий путь даёт 401 — то есть 429 снова
# подтверждал бы существование ручки, ровно то, что чинил флаг-гейт.
dependencies=[
Depends(_require_public_estimate_enabled),
Depends(_budget(_estimate_limiter, "estimate")),
],
) )
async def public_estimate( async def public_estimate(
request: Request, request: Request,
@ -714,8 +754,6 @@ async def public_estimate(
контур (сосед, `/api/v1/trade-in/r/{token}`) открывает его после оплаты по контур (сосед, `/api/v1/trade-in/r/{token}`) открывает его после оплаты по
своему токену. Наружу здесь уезжает только `PublicEstimateResult`. своему токену. Наружу здесь уезжает только `PublicEstimateResult`.
""" """
_enforce(_estimate_limiter, request, "estimate")
daily_retry = _daily_estimate_limiter.retry_after(_ESTIMATE_GLOBAL_KEY) daily_retry = _daily_estimate_limiter.retry_after(_ESTIMATE_GLOBAL_KEY)
if daily_retry is not None: if daily_retry is not None:
logger.error( logger.error(
@ -773,10 +811,13 @@ async def public_estimate(
@router.post( @router.post(
"/estimate/read", "/estimate/read",
response_model=PublicEstimateResult, response_model=PublicEstimateResult,
dependencies=[Depends(_require_public_estimate_enabled)], # Флаг первым — по той же причине, что у `/estimate`.
dependencies=[
Depends(_require_public_estimate_enabled),
Depends(_budget(_estimate_read_limiter, "estimate-read")),
],
) )
def public_estimate_read( def public_estimate_read(
request: Request,
payload: PublicEstimateTokenInput, payload: PublicEstimateTokenInput,
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
) -> PublicEstimateResult: ) -> PublicEstimateResult:
@ -791,8 +832,6 @@ def public_estimate_read(
Просрочка и «нет такого токена» отвечают ОДИНАКОВО (404): различать их Просрочка и «нет такого токена» отвечают ОДИНАКОВО (404): различать их
значит подтверждать существование расчёта тому, кто угадал токен. значит подтверждать существование расчёта тому, кто угадал токен.
""" """
_enforce(_estimate_read_limiter, request, "estimate-read")
row = db.execute( row = db.execute(
text( text(
""" """

View file

@ -0,0 +1,59 @@
"""Ответ об ошибке валидации, который собирается при ЛЮБОМ входе.
ЧТО СЛОМАЛОСЬ
-------------
Аудит живого сайта 30.08.2026: публичная проба покрытия отвечала 500 на входе,
который обязан отсеиваться валидацией::
POST /trade-in/api/public/mera/coverage
{"lat":56.8,"lon":1e400,"rooms":2,"area_m2":50} 500
Разбор. `json.loads` принимает то, чего нет в стандарте JSON: литералы
`Infinity`, `-Infinity`, `NaN`, а `1e400` даёт `inf` переполнением. Pydantic
такое поле честно отбивает по границам (`lon: le=180`) и кладёт значение в
`input` ошибки. Дальше штатный обработчик FastAPI отдаёт перечень ошибок через
`JSONResponse`, а тот сериализует `json.dumps(..., allow_nan=False)` и падает
уже ПОСЛЕ входа в ответ. Наружу это 500, то есть отказ сервера там, где
корректный ответ 422.
ПОЧЕМУ ОДИН ОБРАБОТЧИК, А НЕ ВАЛИДАТОР НА ПОЛЕ
----------------------------------------------
Чинить по одному полю значит починить `lon` и оставить `lat`, `area_m2`,
`limit` и каждое число каждой будущей схемы. Ломается не поле: ломается
сборка ОТВЕТА об ошибке, одна на всё приложение. Здесь она и чинится.
Отдельным модулем (а не строкой в `app/main.py`) ровно затем, чтобы тест мог
поставить ТОТ ЖЕ обработчик на своё маленькое приложение, не втягивая весь
граф роутеров: иначе тестовое приложение отвечало бы иначе, чем прод, и
проверка «422, а не 500» была бы зелёной по построению.
"""
from __future__ import annotations
import math
from fastapi import FastAPI, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
def _json_safe_float(value: float) -> float | str:
"""inf/nan → строка. JSON их не умеет, а на ВХОД они приходят законно."""
return value if math.isfinite(value) else str(value)
async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
"""Тот же стандартный `{"detail": [...]}`, но нефинитное число в `input`
едет строкой ("inf"/"nan") вместо того, чтобы ронять ответ."""
return JSONResponse(
status_code=422,
content=jsonable_encoder(
{"detail": exc.errors()},
custom_encoder={float: _json_safe_float},
),
)
def install_validation_error_handler(app: FastAPI) -> None:
app.add_exception_handler(RequestValidationError, validation_error_handler)

View file

@ -43,6 +43,7 @@ from app.core.auth_db import get_auth_engine
from app.core.config import settings from app.core.config import settings
from app.core.db import SessionLocal from app.core.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping from app.core.fdw import ensure_fdw_user_mapping
from app.core.http_errors import install_validation_error_handler
from app.core.ratelimit import RateLimitMiddleware from app.core.ratelimit import RateLimitMiddleware
from app.core.rbac import rbac_guard from app.core.rbac import rbac_guard
from app.core.request_audit import RequestAuditMiddleware from app.core.request_audit import RequestAuditMiddleware
@ -219,6 +220,10 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# 422 вместо 500 на Infinity/NaN во входе — разбор в app/core/http_errors.py.
install_validation_error_handler(app)
# RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User # RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User
# (см. app/core/auth.py + auth/roles.yaml). Правила: # (см. app/core/auth.py + auth/roles.yaml). Правила:
# 1) Любой non-public path требует X-Authenticated-User — иначе 401. # 1) Любой non-public path требует X-Authenticated-User — иначе 401.

View file

@ -47,6 +47,7 @@ from fastapi.testclient import TestClient # noqa: E402
from app.api.public import mera as public_mera # noqa: E402 from app.api.public import mera as public_mera # noqa: E402
from app.api.v1.geocode import SuggestResponse # noqa: E402 from app.api.v1.geocode import SuggestResponse # noqa: E402
from app.core.db import get_db # noqa: E402 from app.core.db import get_db # noqa: E402
from app.core.http_errors import install_validation_error_handler # noqa: E402
from app.core.rbac import _PUBLIC_PATHS, rbac_guard # noqa: E402 from app.core.rbac import _PUBLIC_PATHS, rbac_guard # noqa: E402
from app.schemas.trade_in import CoverageProbeResponse # noqa: E402 from app.schemas.trade_in import CoverageProbeResponse # noqa: E402
@ -94,6 +95,10 @@ def client() -> TestClient:
""" """
app = FastAPI() app = FastAPI()
app.middleware("http")(rbac_guard) app.middleware("http")(rbac_guard)
# Тот же обработчик 422, что вешает app/main.py. Без него тестовое
# приложение отвечало бы на нефинитные числа иначе, чем прод, и проверка
# «422, а не 500» была бы зелёной по построению.
install_validation_error_handler(app)
app.include_router(public_mera.router, prefix=PREFIX) app.include_router(public_mera.router, prefix=PREFIX)
@app.get("/api/v1/trade-in/coverage") @app.get("/api/v1/trade-in/coverage")
@ -635,3 +640,64 @@ def test_suggest_passes_city_prefixed_query_downstream(client: TestClient) -> No
assert captured["q"] == "Серов, Ленина 1" assert captured["q"] == "Серов, Ленина 1"
# Сам хинт продолжаем передавать: от него зависит гейт кадастрового тира. # Сам хинт продолжаем передавать: от него зависит гейт кадастрового тира.
assert captured["city_hint"] == "Серов" assert captured["city_hint"] == "Серов"
# ── 9. Невалидный вход: 422 и всё тот же бюджет ──────────────────────────────
#
# Аудит живого сайта 30.08.2026: POST /coverage с `"lon":1e400` отвечал 500, и
# двенадцать таких запросов подряд дали двенадцать пятисоток и ни одного 429.
# Две разные поломки в одном месте, поэтому и проверок здесь две.
_JSON = {"content-type": "application/json"}
# `json.loads` принимает нестандартные литералы Infinity/NaN, а `1e400` — это
# переполнение float. Ни одно из этих чисел не сериализуется обратно в JSON,
# поэтому они и роняли ответ об ошибке. Поля берём разные намеренно: чинить
# должно не поле, а сериализацию перечня ошибок.
_NON_FINITE_BODIES = [
b'{"lat":56.838,"lon":1e400,"rooms":2,"area_m2":54.0}',
b'{"lat":56.838,"lon":NaN,"rooms":2,"area_m2":54.0}',
b'{"lat":Infinity,"lon":60.597,"rooms":2,"area_m2":54.0}',
b'{"lat":56.838,"lon":60.597,"rooms":2,"area_m2":-Infinity}',
]
@pytest.mark.parametrize("body", _NON_FINITE_BODIES)
def test_non_finite_number_is_422_not_500(client: TestClient, body: bytes) -> None:
"""Infinity/NaN во входе — это невалидный вход, а не отказ сервера.
Красный вид этого теста без починки не «assert 500 != 422», а
необработанный ValueError из `json.dumps(..., allow_nan=False)`: он летит
сквозь TestClient. Оба исхода одинаково красные и оба про одно: ответ об
ошибке не собрался.
"""
resp = client.post(f"{PREFIX}/coverage", content=body, headers=_JSON)
assert resp.status_code == 422, resp.text
# Форма ответа остаётся стандартной, иначе фронт разбирает её иначе.
assert isinstance(resp.json()["detail"], list)
@pytest.mark.parametrize("route", ["/coverage", "/suggest"])
def test_invalid_body_still_spends_the_per_ip_budget(client: TestClient, route: str) -> None:
"""Бюджет обязан срабатывать РАНЬШЕ разбора тела.
Иначе он не защищает ровно от того, что на разборе тела и падает: клиент
льёт неограниченный поток отказов с одного адреса.
Двусторонность: верните `_enforce(...)` первой строкой тела хендлера и
все ответы станут 422, ни одного 429, тест покраснеет.
"""
limit = public_mera._COVERAGE_LIMIT if route == "/coverage" else public_mera._SUGGEST_LIMIT
codes = [
client.post(
f"{PREFIX}{route}", content=b'{"lat":56.838,"lon":1e400}', headers=_JSON
).status_code
for _ in range(limit + 3)
]
assert 429 in codes, f"бюджет не сработал: {codes}"
# Ничего третьего быть не должно — ни 500, ни внезапной 200 на мусоре.
assert set(codes) <= {422, 429}, codes
# Отказ начинается ровно после исчерпания окна, а не «когда-нибудь».
assert codes.index(429) == limit, codes