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
114 lines
5.3 KiB
Python
114 lines
5.3 KiB
Python
"""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
|
||
)
|