gendesign/tradein-mvp/backend/tests/test_internal_auth_secret.py
bot-backend 07450aeed2
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m43s
fix(tradein/security): defense-in-depth trusted-header auth + rate-limiter fix (#2213)
- shared-secret Caddy↔tradein-backend: X-Internal-Auth-Secret
  (secrets.compare_digest, constant-time), fail-open до провижининга
  TRADEIN_INTERNAL_AUTH_SECRET (WARNING на старте); при заданном секрете
  подделка X-Authenticated-User изнутри docker-сети даёт 401
- rate-limiter: per-user/per-IP ключ вместо освобождения всех authenticated;
  client IP из rightmost XFF (один доверенный прокси Caddy, который
  аппендит real IP); leftmost XFF клиент-контролируем и игнорируется
- Caddyfile: убран глобальный log_credentials (писал обратимые base64
  basic-auth пароли в логи; glitchtip-forwarder деградирует gracefully)
- тесты: secret-gate (401/200/backward-compat/wrong/no-user) + per-user
  rate-limit + XFF-spoof нейтрализован

Провижининг (human, порядок критичен): значение секрета СНАЧАЛА в
окружение Caddy-стека (+recreate caddy), ПОТОМ в рантайм-окружение
tradein-backend (+restart). Обратный порядок = fail-closed 401 на всё
приложение. Значение секрета в репо не попадает.

Refs #2213
2026-07-03 00:07:24 +03:00

157 lines
6.3 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.

"""Defense-in-depth: shared-secret gate поверх trusted-header auth (#2213).
Backend доверяет X-Authenticated-User (его ставит Caddy). На общей docker-сети
gendesign_shared любой контейнер мог бы отправить поддельный
`X-Authenticated-User: admin` напрямую на tradein-backend:8000. Общий секрет
X-Internal-Auth-Secret закрывает дыру: если TRADEIN_INTERNAL_AUTH_SECRET задан,
каждый запрос с X-Authenticated-User обязан нести валидный секрет (constant-time),
иначе 401. Пусто = fail-open (backward-compat до провижининга).
MIRROR of rbac_guard из app/main.py — включая #2213 secret-gate. Держим копию
здесь (как test_rbac.py), чтобы не тянуть тяжёлый app.main (lifespan/DB/scheduler).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import re
import secrets
from collections.abc import Awaitable, Callable
import pytest
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, Response
from fastapi.testclient import TestClient
from app.core import auth as auth_mod
from app.core import config
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
@pytest.fixture(autouse=True)
def _reset_auth_cache() -> None:
auth_mod.reset_cache_for_tests()
def _build_test_app() -> FastAPI:
"""Копия rbac_guard из app/main.py (с #2213 secret-gate)."""
app = FastAPI()
@app.middleware("http")
async def rbac_guard(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
path = request.url.path
if path in _PUBLIC_PATHS:
return await call_next(request)
username = request.headers.get("X-Authenticated-User")
if not username:
return JSONResponse(
status_code=401,
content={"detail": "no authenticated user (Caddy basic_auth required)"},
)
secret = config.settings.tradein_internal_auth_secret
if secret:
provided = request.headers.get("X-Internal-Auth-Secret", "")
if not secrets.compare_digest(provided, secret):
return JSONResponse(
status_code=401,
content={"detail": "invalid or missing internal auth secret"},
)
try:
role = auth_mod.get_role(username)
except KeyError:
return JSONResponse(
status_code=403,
content={"detail": "user not in roles config"},
)
if _ADMIN_API_RE.match(path) and role != "admin":
return JSONResponse(status_code=403, content={"detail": "admin only"})
return await call_next(request)
@app.get("/api/v1/ping")
async def ping() -> dict:
return {"ok": True}
return app
@pytest.fixture
def client() -> TestClient:
return TestClient(_build_test_app())
# ---------------------------------------------------------------------------
# (в) секрет НЕ задан → backward-compat: X-Authenticated-User достаточно.
# ---------------------------------------------------------------------------
def test_secret_unset_backward_compat(client: TestClient, monkeypatch) -> None:
monkeypatch.setattr(config.settings, "tradein_internal_auth_secret", "")
resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "admin"})
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_secret_unset_ignores_provided_secret(client: TestClient, monkeypatch) -> None:
# Если секрет не задан — присланный клиентом X-Internal-Auth-Secret игнорируется.
monkeypatch.setattr(config.settings, "tradein_internal_auth_secret", "")
resp = client.get(
"/api/v1/ping",
headers={"X-Authenticated-User": "admin", "X-Internal-Auth-Secret": "whatever"},
)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# (а) секрет задан + заголовок юзера БЕЗ секрета → 401.
# ---------------------------------------------------------------------------
def test_secret_set_missing_header_401(client: TestClient, monkeypatch) -> None:
monkeypatch.setattr(config.settings, "tradein_internal_auth_secret", "s3cr3t-value")
resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "admin"})
assert resp.status_code == 401
assert "internal auth secret" in resp.json()["detail"].lower()
def test_secret_set_wrong_secret_401(client: TestClient, monkeypatch) -> None:
monkeypatch.setattr(config.settings, "tradein_internal_auth_secret", "s3cr3t-value")
resp = client.get(
"/api/v1/ping",
headers={"X-Authenticated-User": "admin", "X-Internal-Auth-Secret": "wrong"},
)
assert resp.status_code == 401
assert "internal auth secret" in resp.json()["detail"].lower()
# ---------------------------------------------------------------------------
# (б) секрет задан + оба заголовка корректны → 200 (существующее поведение).
# ---------------------------------------------------------------------------
def test_secret_set_correct_secret_200(client: TestClient, monkeypatch) -> None:
monkeypatch.setattr(config.settings, "tradein_internal_auth_secret", "s3cr3t-value")
resp = client.get(
"/api/v1/ping",
headers={"X-Authenticated-User": "admin", "X-Internal-Auth-Secret": "s3cr3t-value"},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_secret_set_no_user_header_still_401(client: TestClient, monkeypatch) -> None:
# Секрет без X-Authenticated-User — no-auth 401 (secret-gate не ослабляет базу).
monkeypatch.setattr(config.settings, "tradein_internal_auth_secret", "s3cr3t-value")
resp = client.get("/api/v1/ping", headers={"X-Internal-Auth-Secret": "s3cr3t-value"})
assert resp.status_code == 401
assert "no authenticated user" in resp.json()["detail"].lower()