gendesign/tradein-mvp/backend/tests/test_internal_auth_secret.py
lekss361 5fb5675871
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Successful in 35s
Deploy Trade-In / build-frontend (push) Successful in 38s
Deploy / build-worker (push) Successful in 42s
Deploy / build-backend (push) Successful in 43s
Deploy / build-frontend (push) Successful in 42s
Deploy / deploy (push) Successful in 1m32s
Deploy Trade-In / test (push) Successful in 1m50s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 51s
fix(tradein/security): defense-in-depth trusted-header auth + rate-limiter fix (#2213) (#2229)
2026-07-02 21:15:58 +00: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()