gendesign/tradein-mvp/backend/tests/test_ratelimit.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

114 lines
5.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.

"""Tests for RateLimitMiddleware — per-user / per-IP sliding window (#655, #2213).
Тестируем middleware в изоляции на минимальном FastAPI-приложении, чтобы не
тянуть тяжёлый 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
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core import config
from app.core.ratelimit import RateLimitMiddleware
@pytest.fixture
def client(monkeypatch):
"""Минимальное приложение: анонимный лимит 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)
@app.get("/api/v1/ping")
def ping() -> dict[str, bool]:
return {"ok": True}
return TestClient(app)
def test_anonymous_throttled_past_limit(client):
# Первые rate_limit запросов проходят, следующий — 429.
for _ in range(3):
assert client.get("/api/v1/ping").status_code == 200
resp = client.get("/api/v1/ping")
assert resp.status_code == 429
assert resp.json() == {"detail": "Слишком много запросов. Попробуйте позже."}
assert "Retry-After" in resp.headers
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) → 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
)