fix(mera/b2c): выключенная ручка расчёта подтверждала своё существование и печатала схему #3233

Merged
bot-backend merged 1 commit from fix/disabled-endpoint-leaks-schema into main 2026-08-29 15:31:27 +00:00
2 changed files with 62 additions and 5 deletions

View file

@ -548,7 +548,25 @@ _TOKEN_TTL = "7 days"
def _require_public_estimate_enabled() -> None:
"""404, пока публичный расчёт не включён владельцем явно."""
"""404, пока публичный расчёт не включён владельцем явно.
ВЕШАЕТСЯ ЧЕРЕЗ `dependencies=[Depends(...)]` НА ДЕКОРАТОР, А НЕ ВЫЗЫВАЕТСЯ
ПЕРВОЙ СТРОКОЙ ТЕЛА. Разница не косметическая: FastAPI решает зависимости
РАНЬШЕ, чем валидирует тело запроса. Пока проверка стояла в теле, до неё
просто не доходило невалидное тело отбивалось 422 ещё на разборе, и
выключенная ручка отвечала так:
POST /api/public/mera/estimate {} 422 + перечень полей
POST /api/public/mera/estimate {валидное} 404
POST /api/public/mera/nosuchthing 401 (rbac)
То есть 422 подтверждал существование ручки (несуществующий путь даёт 401)
и заодно выдавал её схему: address, area_m2, rooms, consent. Замер на проде
29.08.2026, ровно то, что этот докстринг обещал не делать.
Как зависимость проверка срабатывает до разбора тела, и выключенная ручка
неотличима от отсутствующей при ЛЮБОМ входе.
"""
if not settings.public_estimate_enabled:
raise HTTPException(status_code=404, detail="Not Found")
@ -632,7 +650,11 @@ def _coverage_for(
)
@router.post("/estimate", response_model=PublicEstimateResult)
@router.post(
"/estimate",
response_model=PublicEstimateResult,
dependencies=[Depends(_require_public_estimate_enabled)],
)
async def public_estimate(
request: Request,
response: Response,
@ -652,7 +674,6 @@ async def public_estimate(
контур (сосед, `/api/v1/trade-in/r/{token}`) открывает его после оплаты по
своему токену. Наружу здесь уезжает только `PublicEstimateResult`.
"""
_require_public_estimate_enabled()
_enforce(_estimate_limiter, request, "estimate")
daily_retry = _daily_estimate_limiter.retry_after(_ESTIMATE_GLOBAL_KEY)
@ -709,7 +730,11 @@ async def public_estimate(
)
@router.post("/estimate/read", response_model=PublicEstimateResult)
@router.post(
"/estimate/read",
response_model=PublicEstimateResult,
dependencies=[Depends(_require_public_estimate_enabled)],
)
def public_estimate_read(
request: Request,
payload: PublicEstimateTokenInput,
@ -726,7 +751,6 @@ def public_estimate_read(
Просрочка и «нет такого токена» отвечают ОДИНАКОВО (404): различать их
значит подтверждать существование расчёта тому, кто угадал токен.
"""
_require_public_estimate_enabled()
_enforce(_estimate_read_limiter, request, "estimate-read")
row = db.execute(

View file

@ -382,3 +382,36 @@ def test_daily_budget_stops_estimates_for_everyone(client: TestClient, flag_on:
assert resp.status_code == 429, resp.text
assert int(resp.headers["Retry-After"]) > 0
assert not estimate_mock.called
# ── Выключенная ручка не должна подтверждать, что она существует ─────────────
#
# Замер на проде 29.08.2026 (флаг выключен) показал, что обещание докстринга не
# выполнялось: проверка флага стояла ПЕРВОЙ СТРОКОЙ ТЕЛА, а FastAPI валидирует
# тело запроса раньше, чем доходит до кода хендлера. Итог:
# {} → 422 со списком полей address/area_m2/rooms/consent
# {валидное} → 404
# чужой путь → 401
# 422 отличался и от 404, и от 401 — то есть подтверждал существование ручки и
# заодно печатал её схему. Гейт переехал в `dependencies=[Depends(...)]`:
# зависимости решаются ДО разбора тела.
def test_disabled_estimate_hides_itself_from_any_input(client: TestClient) -> None:
for body in ({}, {"address": "x"}, _BODY):
got = client.post(f"{PREFIX}/estimate", json=body)
assert got.status_code == 404, (
f"выключенная ручка ответила {got.status_code} на теле {body!r}. "
"Любой другой код отличает её от несуществующего пути, а 422 "
"вдобавок печатает схему запроса"
)
assert "address" not in got.text, (
"в ответе выключенной ручки видно имя поля запроса — схема утекла"
)
def test_disabled_read_hides_itself_from_any_input(client: TestClient) -> None:
for body in ({}, {"token": "x"}):
got = client.post(f"{PREFIX}/estimate/read", json=body)
assert got.status_code == 404, (
f"выключенное чтение по токену ответило {got.status_code} на {body!r}"
)
assert "token" not in got.text, "схема чтения утекла при выключенном флаге"