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
- 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
157 lines
6.3 KiB
Python
157 lines
6.3 KiB
Python
"""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()
|