From 07450aeed2f944d1b404ae0823311c8b524fa5ca Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 00:07:24 +0300 Subject: [PATCH] fix(tradein/security): defense-in-depth trusted-header auth + rate-limiter fix (#2213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Caddyfile | 24 ++- docker-compose.prod.yml | 6 + tradein-mvp/backend/app/core/config.py | 33 +++- tradein-mvp/backend/app/core/ratelimit.py | 56 +++++-- tradein-mvp/backend/app/main.py | 32 +++- .../tests/test_internal_auth_secret.py | 157 ++++++++++++++++++ tradein-mvp/backend/tests/test_ratelimit.py | 74 ++++++++- tradein-mvp/docker-compose.prod.yml | 6 + 8 files changed, 347 insertions(+), 41 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_internal_auth_secret.py diff --git a/Caddyfile b/Caddyfile index ea7ecbb3..7e7ce21a 100644 --- a/Caddyfile +++ b/Caddyfile @@ -16,15 +16,13 @@ # (basic_auth runs before handle), making /health and /preview/* exclusions # ineffective. With route { }, handlers are matched top-to-bottom as written. -{ - servers { - # ВКЛЮЧАЕТ raw Authorization/Cookie headers в access log - # (по default Caddy 2.x редактирует их как "REDACTED"). - # Plain password будет в gendsgn.ru.log + auth_audit.log — оба root-only на VPS. - # См. forwarder.py _extract_attempted_username для использования. - log_credentials - } -} +# #2213: global `log_credentials` УБРАН. Он заставлял Caddy писать raw +# Authorization header (Base64 `user:password`, тривиально обратимый) в ОБА лог-файла +# (gendsgn.ru.log + auth_audit.log). Даже при root-only доступе и коротком retention +# это plaintext-пароли пилотов на диске — недопустимый риск против скромной выгоды. +# Trade-off: glitchtip-auth-forwarder больше не извлекает `attempted_username` из 401 +# (тег деградирует в "(none)" — forwarder это уже обрабатывает gracefully, не падает). +# Событие basic_auth 401 (remote_ip / uri / method) по-прежнему уходит в GlitchTip. gendsgn.ru { encode zstd gzip @@ -88,6 +86,11 @@ gendsgn.ru { uri strip_prefix /trade-in reverse_proxy tradein-backend:8000 { header_up X-Authenticated-User {http.auth.user.id} + # #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. header_up + # с value ПЕРЕЗАПИСЫВАЕТ (стирает) любой клиентский X-Internal-Auth-Secret — + # тот же механизм, что защищает X-Authenticated-User выше. Пусто пока + # TRADEIN_INTERNAL_AUTH_SECRET не задан в .env (fail-open, backend не проверяет). + header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} } } @@ -108,6 +111,9 @@ gendsgn.ru { # Next.js basePath=/trade-in — фронт сам ждёт префикса в URL reverse_proxy tradein-frontend:3000 { header_up X-Authenticated-User {http.auth.user.id} + # #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env + # (стирает клиентский), на случай SSR-forwardʼa фронтом в backend. + header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} } } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1616b82f..6d7a8cfc 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -270,6 +270,12 @@ services: ports: - "80:80" - "443:443" + environment: + # #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. Caddyfile + # подставляет его в header_up X-Internal-Auth-Secret на /trade-in/* роутах. + # Пустой дефолт = fail-open (backend не проверяет) → ничего не ломается до + # провижининга. Значение задаётся руками в /opt/gendesign/.env на VPS. + TRADEIN_INTERNAL_AUTH_SECRET: ${TRADEIN_INTERNAL_AUTH_SECRET:-} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy/users.caddy.snippet:/etc/caddy/caddy/users.caddy.snippet:ro diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 1486ef59..d4c8499c 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -31,6 +31,23 @@ class Settings(BaseSettings): cors_origins: list[str] = ["http://localhost", "http://localhost:3000", "http://localhost:8080"] environment: str = "dev" + # ── #2213: defense-in-depth для trusted-header auth ─────────────────────── + # Backend доверяет заголовку X-Authenticated-User (его проставляет Caddy после + # basic_auth). Но backend сидит на общей docker-сети gendesign_shared вместе с + # обоими стеками — любой контейнер мог бы отправить поддельный + # `X-Authenticated-User: admin` напрямую на tradein-backend:8000, минуя Caddy, + # и получить админ-доступ. Общий секрет закрывает эту дыру: Caddy добавляет + # X-Internal-Auth-Secret из env, backend требует точного совпадения на любом + # запросе с X-Authenticated-User (constant-time compare). + # + # Дизайн — fail-open до провижининга: пусто (дефолт) → защита НЕ активна + # (поведение как раньше + один WARNING на старте). Секрет задаётся руками в + # .env.runtime ОБОИХ стедов (Caddy главного стека + tradein-backend) и НИКОГДА + # не коммитится. ENV: TRADEIN_INTERNAL_AUTH_SECRET. + tradein_internal_auth_secret: str = Field( + default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET" + ) + # Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/ # backfill_house_coords.py + audit_address_mismatch.py + main backend # OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F). @@ -53,12 +70,20 @@ class Settings(BaseSettings): # Redis URL для hot-cache (Phase 3.2). Задаётся через env REDIS_URL. redis_url: str = "redis://localhost:6379/0" - # Rate-limit публичного /api/* (per-IP sliding window). ENV: RATE_LIMIT, - # RATE_LIMIT_WINDOW_S. Не более rate_limit запросов за rate_limit_window_s - # секунд с одного IP. Аутентифицированный трафик (X-Authenticated-User от - # Caddy basic_auth) не лимитируется — см. ratelimit.py (#655). + # Rate-limit публичного /api/* (per-user / per-IP sliding window). ENV: + # RATE_LIMIT, RATE_LIMIT_WINDOW_S. Не более rate_limit запросов за + # rate_limit_window_s секунд на КЛЮЧ (ключ = authenticated username, либо + # client IP для анонимов). См. ratelimit.py (#655, #2213). rate_limit: int = 300 rate_limit_window_s: float = 60.0 + # #2213: аутентифицированный трафик БОЛЬШЕ не освобождается целиком (это было + # плацебо — заголовок X-Authenticated-User клиент-контролируем). Вместо этого + # per-user лимит = rate_limit × этот множитель. Живой пилот (kopylov/admin) не + # должен упираться — множитель щедрый (×5 = 1500/60с при дефолте), но подделка + # заголовка уже не даёт безлимит. ENV: RATE_LIMIT_AUTHENTICATED_MULTIPLIER. + rate_limit_authenticated_multiplier: int = Field( + default=5, validation_alias="RATE_LIMIT_AUTHENTICATED_MULTIPLIER" + ) # Password for tradein_fdw_reader role — used by backend startup to create/refresh # USER MAPPING for postgres_fdw → gendesign DB (gendesign_remote server). diff --git a/tradein-mvp/backend/app/core/ratelimit.py b/tradein-mvp/backend/app/core/ratelimit.py index d7655f24..acc98b2f 100644 --- a/tradein-mvp/backend/app/core/ratelimit.py +++ b/tradein-mvp/backend/app/core/ratelimit.py @@ -1,10 +1,24 @@ """Простой in-memory rate-limiter для публичного API. -Per-IP sliding window. Достаточно для одного backend-инстанса (MVP). -Защищает от абуза `/api/v1/*` — скрейп-эндпоинты уже за admin-токеном, -но estimate/geocode/suggest публичны. +Sliding window. Достаточно для одного backend-инстанса (MVP). +Защищает `/api/v1/*` от абуза — estimate/geocode/suggest публичны. -Лимиты намеренно щедрые — обычный пользователь их не достигнет, режутся боты. +Ключ лимита (#2213): + - authenticated username (заголовок X-Authenticated-User от Caddy basic_auth) — + per-user лимит = rate_limit × rate_limit_authenticated_multiplier; + - для анонимов — client IP, лимит = rate_limit. + +Раньше весь authenticated-трафик освобождался целиком (#655). Это было плацебо: +заголовок X-Authenticated-User клиент-контролируем (его ставит Caddy, но на общей +docker-сети запрос мог прийти и мимо Caddy). Теперь лимит применяется всегда, но +per-user порог щедрый — живой пилот его не достигнет. + +Допущение по IP (#2213): перед backend ровно ОДИН доверенный прокси (Caddy). +Значит честный клиентский IP = ПОСЛЕДНИЙ (rightmost) элемент X-Forwarded-For, +добавленный самим Caddy. Leftmost-элементы клиент может подделать (X-Forwarded-For: +"1.2.3.4" в исходном запросе), поэтому брать leftmost — дыра (тривиальный обход +per-IP лимита сменой фейкового первого хопа). Если XFF пуст — remote_addr +соединения. """ from __future__ import annotations @@ -18,10 +32,6 @@ from starlette.middleware.base import BaseHTTPMiddleware from app.core.config import settings -# Окно и лимит читаются из settings (env RATE_LIMIT / RATE_LIMIT_WINDOW_S): -# не более settings.rate_limit запросов за settings.rate_limit_window_s секунд -# с одного IP. - class RateLimitMiddleware(BaseHTTPMiddleware): """Sliding-window rate limit на /api/v1/*. Health и статика — без лимита.""" @@ -36,22 +46,24 @@ class RateLimitMiddleware(BaseHTTPMiddleware): if not path.startswith("/api/"): return await call_next(request) - # Аутентифицированный трафик не лимитируем: Caddy basic_auth ставит - # X-Authenticated-User на каждый запрос пилота. Лимит — только для - # анонимного трафика (заголовок отсутствует/пуст). #655. - if request.headers.get("x-authenticated-user"): - return await call_next(request) + # Ключ и порог: per-user для аутентифицированных, per-IP для анонимов. + username = request.headers.get("x-authenticated-user") + if username: + key = f"user:{username}" + limit = settings.rate_limit * settings.rate_limit_authenticated_multiplier + else: + key = f"ip:{_client_ip(request)}" + limit = settings.rate_limit - ip = _client_ip(request) now = time.monotonic() - bucket = self._hits[ip] + bucket = self._hits[key] # Выкидываем устаревшие отметки за пределами окна. cutoff = now - settings.rate_limit_window_s while bucket and bucket[0] < cutoff: bucket.popleft() - if len(bucket) >= settings.rate_limit: + if len(bucket) >= limit: retry = int(settings.rate_limit_window_s - (now - bucket[0])) + 1 return JSONResponse( status_code=429, @@ -69,8 +81,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware): def _client_ip(request: Request) -> str: - """Реальный IP за реверс-прокси (Caddy/nginx ставят X-Forwarded-For).""" + """Честный клиентский IP при РОВНО ОДНОМ доверенном прокси (Caddy) перед нами. + + Caddy добавляет свой хоп в конец X-Forwarded-For, поэтому берём ПОСЛЕДНИЙ + (rightmost) элемент — его нельзя подделать с клиента. Leftmost элементы + клиент-контролируемы и для ключа лимита НЕ используются. Пустой XFF (прямое + соединение) → remote_addr. + """ xff = request.headers.get("x-forwarded-for") if xff: - return xff.split(",")[0].strip() + parts = [p.strip() for p in xff.split(",") if p.strip()] + if parts: + return parts[-1] return request.client.host if request.client else "unknown" diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index ea1c8b7d..c770acc1 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -10,6 +10,7 @@ import asyncio import logging import os import re +import secrets from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager @@ -66,6 +67,16 @@ if settings.glitchtip_dsn: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + # #2213 defense-in-depth: если общий секрет не задан — trusted-header auth + # уязвим к подделке X-Authenticated-User изнутри docker-сети gendesign_shared. + # Один явный WARNING на старте, чтобы это не осталось незамеченным в проде. + if not settings.tradein_internal_auth_secret: + logger.warning( + "defense-in-depth НЕ активен: X-Authenticated-User принимается без " + "проверки внутреннего секрета — задай TRADEIN_INTERNAL_AUTH_SECRET в " + ".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)" + ) + # FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server. # Best-effort: failure does not abort startup, just logs. try: @@ -129,6 +140,25 @@ async def rbac_guard( content={"detail": "no authenticated user (Caddy basic_auth required)"}, ) + # #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User + # ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env). + # Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401. + # Constant-time compare против timing-атак. Пусто = защита не активна (fail-open). + secret = settings.tradein_internal_auth_secret + if secret: + provided = request.headers.get("X-Internal-Auth-Secret", "") + if not secrets.compare_digest(provided, secret): + logger.warning( + "RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret " + "на %s — возможная подделка заголовка мимо Caddy", + username, + path, + ) + return JSONResponse( + status_code=401, + content={"detail": "invalid or missing internal auth secret"}, + ) + try: role = get_role(username) except KeyError: @@ -154,7 +184,7 @@ app.add_middleware( allow_methods=["*"], allow_headers=["*"], ) -# Rate-limit публичного API (per-IP sliding window) — защита от абуза. +# Rate-limit публичного API (per-user / per-IP sliding window) — защита от абуза. app.add_middleware(RateLimitMiddleware) diff --git a/tradein-mvp/backend/tests/test_internal_auth_secret.py b/tradein-mvp/backend/tests/test_internal_auth_secret.py new file mode 100644 index 00000000..bf94d7d8 --- /dev/null +++ b/tradein-mvp/backend/tests/test_internal_auth_secret.py @@ -0,0 +1,157 @@ +"""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() diff --git a/tradein-mvp/backend/tests/test_ratelimit.py b/tradein-mvp/backend/tests/test_ratelimit.py index bc891851..bf5fba49 100644 --- a/tradein-mvp/backend/tests/test_ratelimit.py +++ b/tradein-mvp/backend/tests/test_ratelimit.py @@ -1,8 +1,13 @@ -"""Tests for RateLimitMiddleware — per-IP sliding window + auth bypass (#655). +"""Tests for RateLimitMiddleware — per-user / per-IP sliding window (#655, #2213). Тестируем middleware в изоляции на минимальном FastAPI-приложении, чтобы не -тянуть тяжёлый app.main (DB / scheduler / sentry). Лимит/окно читаются из -settings на каждый dispatch → monkeypatch'им их в маленькие значения. +тянуть тяжёлый app.main (DB / scheduler / sentry). Лимит/окно/множитель читаются +из settings на каждый dispatch → monkeypatch'им их в маленькие значения. + +#2213: аутентифицированный трафик больше НЕ освобождается целиком — он лимитируется +per-user с щедрым порогом (rate_limit × multiplier). Анонимный ключ = client IP, +взятый из RIGHTMOST элемента X-Forwarded-For (ровно один доверенный прокси Caddy), +поэтому подделка leftmost XFF не меняет ключ. """ import os @@ -19,9 +24,10 @@ from app.core.ratelimit import RateLimitMiddleware @pytest.fixture def client(monkeypatch): - """Минимальное приложение с лимитом 3 запроса / 60 с (для быстрого 429).""" + """Минимальное приложение: анонимный лимит 3 / 60 с, auth-множитель ×2 (→6).""" monkeypatch.setattr(config.settings, "rate_limit", 3) monkeypatch.setattr(config.settings, "rate_limit_window_s", 60.0) + monkeypatch.setattr(config.settings, "rate_limit_authenticated_multiplier", 2) app = FastAPI() app.add_middleware(RateLimitMiddleware) @@ -43,16 +49,66 @@ def test_anonymous_throttled_past_limit(client): assert "Retry-After" in resp.headers -def test_authenticated_user_bypasses_limit(client): - # X-Authenticated-User (Caddy basic_auth) → лимит не применяется. - headers = {"X-Authenticated-User": "pilot@example.com"} - for _ in range(10): # сильно больше rate_limit=3 +def test_authenticated_user_is_limited_per_user(client): + # #2213: authenticated НЕ освобождается — лимит = rate_limit × multiplier = 6. + headers = {"X-Authenticated-User": "alice"} + for _ in range(6): + assert client.get("/api/v1/ping", headers=headers).status_code == 200 + # 7-й запрос того же юзера — 429. + assert client.get("/api/v1/ping", headers=headers).status_code == 429 + + +def test_authenticated_limit_higher_than_anonymous(client): + # Auth-порог (6) строго выше анонимного (3): 4-й запрос юзера ещё проходит, + # тогда как анонимный на 4-м уже режется (см. test_anonymous_throttled_past_limit). + headers = {"X-Authenticated-User": "alice"} + for _ in range(4): assert client.get("/api/v1/ping", headers=headers).status_code == 200 +def test_per_user_key_isolation(client): + # Разные юзеры — независимые корзины: alice упирается, bob не затронут. + alice = {"X-Authenticated-User": "alice"} + bob = {"X-Authenticated-User": "bob"} + for _ in range(6): + assert client.get("/api/v1/ping", headers=alice).status_code == 200 + assert client.get("/api/v1/ping", headers=alice).status_code == 429 + # bob со своим ключом проходит свободно. + assert client.get("/api/v1/ping", headers=bob).status_code == 200 + + def test_empty_auth_header_does_not_bypass(client): - # Пустой заголовок не должен no-op'ить лимит (header present но falsy). + # Пустой заголовок не должен no-op'ить лимит (header present но falsy) → per-IP. headers = {"X-Authenticated-User": ""} for _ in range(3): assert client.get("/api/v1/ping", headers=headers).status_code == 200 assert client.get("/api/v1/ping", headers=headers).status_code == 429 + + +def test_leftmost_xff_does_not_affect_anon_key(client): + # Клиент подделывает leftmost XFF, но rightmost (добавленный Caddy) одинаков → + # один ключ ip:9.9.9.9 → лимит общий, подделкой его не обойти. + spoofed = [ + {"X-Forwarded-For": "1.1.1.1, 9.9.9.9"}, + {"X-Forwarded-For": "2.2.2.2, 9.9.9.9"}, + {"X-Forwarded-For": "3.3.3.3, 9.9.9.9"}, + ] + for h in spoofed: + assert client.get("/api/v1/ping", headers=h).status_code == 200 + # 4-й (снова другой leftmost, тот же rightmost) — уже за лимитом. + resp = client.get("/api/v1/ping", headers={"X-Forwarded-For": "4.4.4.4, 9.9.9.9"}) + assert resp.status_code == 429 + + +def test_anonymous_key_from_rightmost_xff(client): + # Разный rightmost = разные реальные клиенты = независимые корзины. + for _ in range(3): + assert ( + client.get("/api/v1/ping", headers={"X-Forwarded-For": "8.8.8.8, 1.1.1.1"}).status_code + == 200 + ) + # Другой rightmost — своя корзина, не затронут лимитом первого. + assert ( + client.get("/api/v1/ping", headers={"X-Forwarded-For": "8.8.8.8, 2.2.2.2"}).status_code + == 200 + ) diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index d3a3d12f..88dadf38 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -88,6 +88,12 @@ services: # Scheduler отключён в API-процессе — он живёт в tradein-scraper (#1182). # Это предотвращает двойной запуск sweep'ов при наличии обоих контейнеров. SCHEDULER_ENABLE: "false" + # #2213 defense-in-depth: общий секрет с Caddy. ЗАДАН → backend требует + # валидный X-Internal-Auth-Secret на каждом запросе с X-Authenticated-User + # (иначе 401). Пусто = защита не активна (fail-open до провижининга). + # Значение ДОЛЖНО совпадать с TRADEIN_INTERNAL_AUTH_SECRET в .env главного + # (Caddy) стека. Читается также из backend/.env.runtime (env_file выше). + TRADEIN_INTERNAL_AUTH_SECRET: "${TRADEIN_INTERNAL_AUTH_SECRET:-}" depends_on: browser: condition: service_started