Files
astrololo/services/presentation/tests/test_security.py
T
gitea 91644a44e3
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m47s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 31s
Testy / Kontrola składni wszystkich warstw (push) Successful in 21s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m53s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 32s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 20s
fix(prezentacja): limit zadan po adresie klienta, nie proxy (PRE-16)
Po wlaczeniu TLS aplikacja stanie za Ingressem, a wtedy `request.client.host`
to adres POD-a Traefika — jednakowy dla wszystkich. Limiter wrzucalby caly ruch
do jednego wiadra 120/min i pierwsza osoba, ktora go wyklika, odcielaby
pozostalych. Cicha regresja, ktora ujawnilaby sie dopiero na produkcji.

Nowe `client_ip()` czyta adres z naglowka, ale WYLACZNIE przy TRUST_PROXY —
bo inaczej wystarczyloby dopisywac wlasny X-Forwarded-For, zeby przy kazdym
zadaniu wygladac na kogos innego i ominac limit calkowicie. Z tego samego
powodu bierzemy OSTATNI wpis listy: to jedyny, ktory dopisal nasz proxy;
wczesniejsze mogl podstawic klient, wiec nie znacza nic.

Szesc testow, w tym dwa istotne:
- podszycie sie pod X-Forwarded-For NIE resetuje wiadra przy wylaczonym
  TRUST_PROXY (inaczej baze dalo by sie pompowac bez ograniczen),
- za proxy dwa rozne adresy dostaja osobne wiadra i nie odcinaja sie nawzajem.

Oba sprawdzone celowym zepsuciem implementacji (zawsze ufaj naglowkowi +
bierz pierwszy wpis) — testy wtedy czerwienieja. 23 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:26:57 +02:00

202 lines
7.3 KiB
Python

"""Logowanie do aplikacji i limit żądań (LOG-32).
To jest brama chroniąca oryginalne bazy interpretacyjne — bez niej każdy w sieci
mógł je wypompować przez `/significators` czy generator promptu. Testy pilnują,
że brama faktycznie zamyka, a nie tylko wygląda na zamkniętą.
"""
import base64
import pytest
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from starlette.testclient import TestClient
from app import security
USER, PASSWORD = "astrololo", "haslo-testowe"
def _basic(user: str, password: str) -> dict[str, str]:
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {raw}"}
def _app() -> FastAPI:
app = FastAPI()
security.install(app)
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/significators", response_class=HTMLResponse)
def significators():
return "<p>treść z bazy interpretacyjnej</p>"
return app
@pytest.fixture(autouse=True)
def _reset_rate_limit():
security._hits.clear()
yield
security._hits.clear()
@pytest.fixture
def guarded(monkeypatch):
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "120")
return TestClient(_app())
# ------------------------------------------------------------------ logowanie
def test_blocks_anonymous_access(guarded):
r = guarded.get("/significators")
assert r.status_code == 401
assert "treść z bazy" not in r.text
def test_challenges_with_basic_realm(guarded):
"""Bez nagłówka WWW-Authenticate przeglądarka nie pokaże okna logowania."""
assert "Basic" in guarded.get("/significators").headers.get("WWW-Authenticate", "")
def test_rejects_wrong_password(guarded):
assert guarded.get("/significators", headers=_basic(USER, "zle")).status_code == 401
def test_rejects_wrong_user(guarded):
assert guarded.get("/significators", headers=_basic("obcy", PASSWORD)).status_code == 401
def test_rejects_malformed_header(guarded):
for bad in ("Basic !!!niebase64!!!", "Bearer cokolwiek", "", "Basic"):
assert guarded.get("/significators", headers={"Authorization": bad}).status_code == 401
def test_allows_correct_credentials(guarded):
r = guarded.get("/significators", headers=_basic(USER, PASSWORD))
assert r.status_code == 200
assert "treść z bazy" in r.text
def test_health_stays_public(guarded):
assert guarded.get("/health").status_code == 200
def test_open_when_password_unset(monkeypatch):
"""Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie)."""
monkeypatch.delenv("APP_PASSWORD", raising=False)
assert not security.auth_enabled()
assert TestClient(_app()).get("/significators").status_code == 200
# --------------------------------------------------------------- limit żądań
def test_rate_limit_blocks_flood(monkeypatch):
"""Masowe odpytywanie to droga eksfiltracji nawet po zalogowaniu."""
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "5")
client = TestClient(_app())
auth = _basic(USER, PASSWORD)
codes = [client.get("/significators", headers=auth).status_code for _ in range(8)]
assert codes[:5] == [200] * 5
assert 429 in codes[5:]
def test_rate_limited_response_has_retry_after(monkeypatch):
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "1")
client = TestClient(_app())
auth = _basic(USER, PASSWORD)
client.get("/significators", headers=auth)
r = client.get("/significators", headers=auth)
assert r.status_code == 429 and r.headers.get("Retry-After") == "60"
def test_rate_limit_precedes_auth(monkeypatch):
"""Limit musi działać także dla niezalogowanych — inaczej zgadywanie hasła
i sondowanie API jest darmowe."""
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
client = TestClient(_app())
codes = [client.get("/significators").status_code for _ in range(6)]
assert 429 in codes
def test_rate_limit_disabled_when_zero(monkeypatch):
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
monkeypatch.delenv("APP_PASSWORD", raising=False)
client = TestClient(_app())
assert all(client.get("/significators").status_code == 200 for _ in range(30))
# ------------------------------------------ adres klienta za odwrotnym proxy
#
# Po włączeniu TLS (PRE-16) aplikacja stoi za Ingressem, więc bezpośredni peer
# to zawsze POD proxy. Te testy pilnują obu stron kompromisu: żeby limit dalej
# rozróżniał ludzi, a jednocześnie żeby nagłówek nie stał się furtką do jego
# ominięcia.
class _Req:
"""Minimalny zamiennik Request — `client_ip` czyta tylko te dwa pola."""
def __init__(self, peer: str | None, **headers: str):
self.client = type("C", (), {"host": peer})() if peer else None
self.headers = {k.replace("_", "-"): v for k, v in headers.items()}
def test_client_ip_ignores_headers_without_trust_proxy(monkeypatch):
"""Bez TRUST_PROXY nagłówek jest bezwartościowy — każdy może go dopisać."""
monkeypatch.delenv("TRUST_PROXY", raising=False)
req = _Req("10.42.0.7", x_forwarded_for="1.2.3.4", x_real_ip="5.6.7.8")
assert security.client_ip(req) == "10.42.0.7"
def test_client_ip_takes_last_forwarded_entry(monkeypatch):
"""Ostatni wpis dopisał NASZ proxy; wcześniejsze mógł podstawić klient."""
monkeypatch.setenv("TRUST_PROXY", "true")
req = _Req("10.42.0.7", x_forwarded_for="1.1.1.1, 2.2.2.2, 192.168.1.50")
assert security.client_ip(req) == "192.168.1.50"
def test_client_ip_falls_back_to_real_ip(monkeypatch):
monkeypatch.setenv("TRUST_PROXY", "true")
req = _Req("10.42.0.7", x_real_ip="192.168.1.50")
assert security.client_ip(req) == "192.168.1.50"
def test_client_ip_falls_back_to_peer_when_headers_missing(monkeypatch):
monkeypatch.setenv("TRUST_PROXY", "true")
assert security.client_ip(_Req("10.42.0.7")) == "10.42.0.7"
def test_spoofed_forwarded_header_cannot_dodge_the_limit(monkeypatch):
"""Sedno sprawy: bez zaufania do proxy podszywanie się NIE resetuje wiadra.
Gdyby limiter brał pierwszy lepszy `X-Forwarded-For`, wystarczyłoby zmieniać
go co żądanie, żeby pompować bazę bez ograniczeń.
"""
monkeypatch.delenv("TRUST_PROXY", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
monkeypatch.delenv("APP_PASSWORD", raising=False)
client = TestClient(_app())
codes = [client.get("/significators", headers={"X-Forwarded-For": f"9.9.9.{i}"}).status_code
for i in range(6)]
assert 429 in codes
def test_proxied_clients_get_separate_buckets(monkeypatch):
"""Za proxy dwie różne osoby nie mogą się nawzajem odcinać."""
monkeypatch.setenv("TRUST_PROXY", "true")
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
monkeypatch.delenv("APP_PASSWORD", raising=False)
client = TestClient(_app())
first = [client.get("/significators", headers={"X-Forwarded-For": "192.168.1.50"}).status_code
for _ in range(5)]
second = client.get("/significators", headers={"X-Forwarded-For": "192.168.1.51"})
assert 429 in first, "limit musi zadziałać dla pierwszego adresu"
assert second.status_code == 200, "drugi adres ma własne wiadro"