fix(tradein/rbac): аноним на admin-префиксе получает единый 404 — существование admin-ручек снаружи не перебирается #3371

Merged
bot-backend merged 3 commits from fix/3360-admin-prefix-anon-404 into main 2026-09-05 20:32:06 +00:00
3 changed files with 114 additions and 15 deletions
Showing only changes of commit 990a59e469 - Show all commits

View file

@ -209,7 +209,17 @@ check_post "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/ap
# открытом наружу бэкенде.
check "trade-in /api/v1/me — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/me" 401
check "trade-in /api/v1/history — 401 anonymous (чужие оценки)" "$BASE_MAIN/trade-in/api/v1/history" 401
check "trade-in /api/v1/admin/* — 401 anonymous" "$BASE_MAIN/trade-in/api/v1/admin/users" 401
# #3360: admin-префикс анониму отвечает 404, а НЕ 401. Периметр здесь не срезан
# (Caddy-блок /trade-in/api/* стоит выше basic_auth-снипета, и срезать нельзя —
# admin-UI зовёт эти же пути из браузера), поэтому существование ручки прячет сам
# guard. Пара путей взята намеренно разная: `/proxies` — РЕАЛЬНЫЙ роут
# (app/api/v1/admin.py), `/users` — несуществующий. Одинаковый код на обоих и
# означает, что перебором имён admin-API снаружи ничего не узнать; 401 на первом
# = регресс маскировки (app/core/rbac.py::_unauthenticated).
check "trade-in /api/v1/admin/proxies — 404 anonymous (существующая ручка скрыта)" \
"$BASE_MAIN/trade-in/api/v1/admin/proxies" 404
check "trade-in /api/v1/admin/users — 404 anonymous (несуществующая — тот же ответ)" \
"$BASE_MAIN/trade-in/api/v1/admin/users" 404
# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга
# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается

View file

@ -238,6 +238,34 @@ def _path_is_routed(request: Request) -> bool:
return any(route.matches(request.scope)[0] != Match.NONE for route in router.routes)
def _unauthenticated(path: str, detail: str) -> JSONResponse:
"""Отказ анониму: 401 везде, но на admin-префиксе — 404 роутера.
#3360: периметр admin-путей снаружи НЕ срезан (``caddy/sites/apps.caddy``,
блок ``handle /trade-in/api/*`` стоит выше ``import
caddy/users.caddy.snippet``), и срезать его нельзя admin-UI кабинета зовёт
``/api/v1/admin/*`` ИЗ БРАУЗЕРА (tradein-mvp/frontend/src/app/scrapers/**,
components/scrapers/**, lib/admin-audit-api.ts). Значит внешний аноним
доходит сюда, а после #3324 (несуществующий путь → 404 роутера) 401 на
существующей ручке работал оракулом: перебором имён восстанавливался список
admin-API. Отвечаем ТЕМ ЖЕ, что роутер отдаёт на несуществующий путь,
существующая и несуществующая admin-ручки анониму неразличимы.
Скрываем ровно от НЕаутентифицированного. Аутентифицированный не-admin
по-прежнему получает 403 «admin only»: он уже прошёл идентификацию, прятать
от него наличие ручки незачем, а 404 вместо 403 маскировал бы отладку.
Тело литерал ``{"detail": "Not Found"}``: это ответ дефолтного
http_exception_handler FastAPI, тот же, что придёт из роутера. Тест
сравнивает два ЖИВЫХ ответа между собой, а не с этой константой, если
фреймворк сменит формулировку, покраснеет он, а не прод.
"""
if _ADMIN_API_RE.match(path):
logger.info("RBAC: anonymous probe of admin path %s — cloaked as 404", path)
return JSONResponse(status_code=404, content={"detail": "Not Found"})
return JSONResponse(status_code=401, content={"detail": detail})
def _propagate_authenticated_user(request: Request, username: str) -> None:
"""Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
только добавляя при отсутствии, чтобы ``RateLimitMiddleware``/
@ -340,18 +368,12 @@ async def rbac_guard(
# auth_mode == "db_only" — легаси trusted-header путь ПОЛНОСТЬЮ
# отключён, даже если валидный X-Authenticated-User присутствует.
if settings.auth_mode != "dual":
return JSONResponse(
status_code=401,
content={"detail": "valid session required"},
)
return _unauthenticated(path, "valid session required")
# ---- legacy trusted-header path — BIT-FOR-BIT как было до #2552 ----
username = request.headers.get("X-Authenticated-User")
if not username:
return JSONResponse(
status_code=401,
content={"detail": "no authenticated user (valid session required)"},
)
return _unauthenticated(path, "no authenticated user (valid session required)")
# #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
# ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
@ -367,10 +389,7 @@ async def rbac_guard(
username,
path,
)
return JSONResponse(
status_code=401,
content={"detail": "invalid or missing internal auth secret"},
)
return _unauthenticated(path, "invalid or missing internal auth secret")
try:
role = get_role(username)

View file

@ -34,6 +34,7 @@ from fastapi.testclient import TestClient
from app.api.v1 import me as me_router
from app.core import auth as auth_mod
from app.core import config
from app.core.rbac import _db_role_path_allowed, rbac_guard
@ -270,9 +271,14 @@ def test_rbac_guard_admin_allowed(client: TestClient) -> None:
assert resp.json() == {"ok": True}
def test_rbac_guard_no_header_on_admin_path_returns_401(client: TestClient) -> None:
def test_rbac_guard_no_header_on_admin_path_returns_404(client: TestClient) -> None:
"""#3360: было 401 («ручка есть, но не для тебя»), стало 404 — как у
несуществующего пути. Доступа это не меняет (запрос по-прежнему отбит ДО
хендлера), меняется только то, что аноним узнаёт из ответа. Неразличимость
с несуществующей ручкой проверяется ниже,
test_anon_cannot_tell_existing_admin_route_from_missing_one."""
resp = client.get("/api/v1/admin/dummy")
assert resp.status_code == 401
assert resp.status_code == 404
def test_rbac_guard_unknown_user_on_admin_path_returns_403(client: TestClient) -> None:
@ -419,6 +425,70 @@ def test_rbac_guard_unrouted_path_answers_the_same_everywhere(client: TestClient
assert client.get("/api/v1/trade-in/support/anon/unread").status_code == 200
def test_anon_cannot_tell_existing_admin_route_from_missing_one(client: TestClient) -> None:
"""#3360: анониму СУЩЕСТВУЮЩАЯ admin-ручка отвечает ровно как несуществующая.
Периметр admin-путей снаружи не срезан (`caddy/sites/apps.caddy`: блок
`handle /trade-in/api/*` выше `import caddy/users.caddy.snippet`) и срезать
его нельзя admin-UI кабинета зовёт `/api/v1/admin/*` ИЗ БРАУЗЕРА
(tradein-mvp/frontend/src/app/scrapers/**, lib/admin-audit-api.ts). Значит
внешний аноним доходит до guard'а, а после #3324 (несуществующий путь → 404
роутера) 401 на существующей ручке работал оракулом имён admin-API.
Сравниваются два ЖИВЫХ ответа между собой, а не с константой: важна именно
неразличимость, а не конкретная формулировка тела от FastAPI.
"""
existing = client.get("/api/v1/admin/dummy") # роут есть — см. _build_test_app
missing = client.get("/api/v1/admin/zzz-no-such-handle")
assert (existing.status_code, existing.json()) == (missing.status_code, missing.json()), (
f"существующая → {existing.status_code} {existing.json()}, "
f"несуществующая → {missing.status_code} {missing.json()}"
)
assert existing.status_code == 404
# Маскировка НЕ трогает аутентифицированных: admin работает как раньше,
# а не-admin получает свой 403 «admin only» — прятать наличие ручки от
# опознанного человека незачем (и это ломало бы отладку прав).
ok = client.get("/api/v1/admin/dummy", headers={"X-Authenticated-User": "admintest"})
assert ok.status_code == 200, ok.text
denied = client.get("/api/v1/admin/dummy", headers={"X-Authenticated-User": "analysttest"})
assert denied.status_code == 403, denied.text
assert denied.json()["detail"] == "admin only"
# non-admin путь анониму — по-прежнему 401 (#3324 оставил его как есть).
assert client.get("/api/v1/me").status_code == 401
def test_anon_admin_cloak_covers_all_unauthenticated_branches(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Все три ветки «личность не установлена» на admin-префиксе дают тот же 404.
Одной ветки мало: guard отказывает анониму в трёх местах (нет заголовка,
auth_mode=db_only, подделанный X-Authenticated-User без #2213-секрета), и
любая пропущенная снова становится оракулом существования ручки.
"""
baseline = client.get("/api/v1/admin/zzz-no-such-handle")
monkeypatch.setattr(config.settings, "tradein_internal_auth_secret", "s3cr3t-value")
forged = client.get("/api/v1/admin/dummy", headers={"X-Authenticated-User": "admintest"})
assert (forged.status_code, forged.json()) == (baseline.status_code, baseline.json()), (
f"подделка мимо Caddy → {forged.status_code} {forged.json()}"
)
# Тот же запрос на non-admin пути секрет-гейт по-прежнему отбивает 401 —
# маскировка живёт ровно на admin-префиксе, а не поверх всего guard'а.
non_admin = client.get("/api/v1/me", headers={"X-Authenticated-User": "admintest"})
assert non_admin.status_code == 401
assert "internal auth secret" in non_admin.json()["detail"].lower()
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
db_only = client.get("/api/v1/admin/dummy")
assert (db_only.status_code, db_only.json()) == (baseline.status_code, baseline.json()), (
f"db_only без сессии → {db_only.status_code} {db_only.json()}"
)
assert client.get("/api/v1/me").status_code == 401
def test_real_app_does_not_redirect_trailing_slash() -> None:
"""#3324: `/api/v1/me/` анониму → 404, а не 307 на существующий путь.