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
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>
152 lines
5.2 KiB
Python
152 lines
5.2 KiB
Python
"""Ochrona dostępu do aplikacji (LOG-32).
|
|
|
|
Rdzeniem produktu są oryginalne bazy interpretacyjne. Aplikacja podaje ich treść
|
|
na wielu ścieżkach (`/significators`, `/interpret`, generator promptu), więc BRAK
|
|
uwierzytelnienia oznacza, że każdy w sieci może je wypompować — bez udziału
|
|
jakiegokolwiek modelu językowego. Ten moduł zamyka tę drogę.
|
|
|
|
Dwa mechanizmy:
|
|
* **HTTP Basic** — wejście do aplikacji; włącza się, gdy ustawiono APP_PASSWORD.
|
|
* **limit żądań** — hamuje masowe odpytywanie (eksfiltrację przez pętlę zapytań);
|
|
rozliczany per adres klienta, a za odwrotnym proxy — po TRUST_PROXY=true —
|
|
per adres z nagłówka, nie per adres proxy (patrz `client_ip`).
|
|
|
|
Świadomie NIE logujemy treści żądań ani promptów — logi to kolejny nośnik wycieku.
|
|
|
|
UWAGA: bez APP_PASSWORD ochrona jest WYŁĄCZONA (zgodność wstecz i wygoda dev).
|
|
Wtedy przy starcie leci głośne ostrzeżenie — żeby nikt nie wdrożył tego w
|
|
przekonaniu, że jest chroniony.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import logging
|
|
import os
|
|
import secrets
|
|
import time
|
|
from collections import deque
|
|
|
|
from fastapi import Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
|
|
log = logging.getLogger("astrololo.security")
|
|
|
|
MAX_TRACKED_CLIENTS_DEFAULT = 4096
|
|
MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci
|
|
|
|
|
|
# konfiguracja czytana leniwie — testy i restart mogą ją zmienić bez importu modułu
|
|
def app_user() -> str:
|
|
return os.getenv("APP_USER", "astrololo")
|
|
|
|
|
|
def app_password() -> str:
|
|
return os.getenv("APP_PASSWORD", "")
|
|
|
|
|
|
def rate_limit_per_min() -> int:
|
|
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
|
|
|
|
|
|
def trust_proxy() -> bool:
|
|
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
PUBLIC_PATHS = frozenset({"/health"})
|
|
PUBLIC_PREFIXES = ("/static/",)
|
|
|
|
_hits: dict[str, deque[float]] = {}
|
|
|
|
|
|
def auth_enabled() -> bool:
|
|
return bool(app_password())
|
|
|
|
|
|
def _is_public(path: str) -> bool:
|
|
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
|
|
|
|
|
|
def _authorized(header: str | None) -> bool:
|
|
if not header or not header.lower().startswith("basic "):
|
|
return False
|
|
try:
|
|
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
|
|
user, _, password = raw.partition(":")
|
|
except (binascii.Error, UnicodeDecodeError, IndexError):
|
|
return False
|
|
# porównanie odporne na atak czasowy; oba pola muszą się zgadzać
|
|
ok_user = secrets.compare_digest(user, app_user())
|
|
ok_pass = secrets.compare_digest(password, app_password())
|
|
return ok_user and ok_pass
|
|
|
|
|
|
def client_ip(request: Request) -> str:
|
|
"""Adres, po którym rozliczamy limit żądań.
|
|
|
|
Za odwrotnym proxy (u nas: Ingress/Traefik po włączeniu TLS — PRE-16)
|
|
`request.client.host` to adres POD-a proxy, jednakowy dla wszystkich. Bez
|
|
poprawki cały ruch trafiałby do jednego wiadra i pierwsza osoba, która
|
|
wyklika limit, odcięłaby pozostałe.
|
|
|
|
Nagłówkom wierzymy WYŁĄCZNIE przy TRUST_PROXY — bo inaczej wystarczyłoby
|
|
dopisać własny `X-Forwarded-For`, żeby przy każdym żądaniu wyglądać na kogoś
|
|
innego i ominąć limit całkowicie. Z tego samego powodu bierzemy OSTATNI wpis
|
|
listy: to jedyny, który dopisał nasz proxy. Wcześniejsze mógł podstawić
|
|
klient, więc nie znaczą nic.
|
|
"""
|
|
peer = request.client.host if request.client else "?"
|
|
if not trust_proxy():
|
|
return peer
|
|
forwarded = request.headers.get("x-forwarded-for", "")
|
|
if forwarded:
|
|
last = forwarded.rsplit(",", 1)[-1].strip()
|
|
if last:
|
|
return last
|
|
return request.headers.get("x-real-ip", "").strip() or peer
|
|
|
|
|
|
def _rate_limited(client: str) -> bool:
|
|
cap = rate_limit_per_min()
|
|
if cap <= 0:
|
|
return False
|
|
now = time.monotonic()
|
|
window = _hits.get(client)
|
|
if window is None:
|
|
if len(_hits) >= MAX_TRACKED_CLIENTS:
|
|
_hits.clear() # prosty reset zamiast nieograniczonego wzrostu
|
|
window = _hits[client] = deque()
|
|
while window and now - window[0] > 60.0:
|
|
window.popleft()
|
|
if len(window) >= cap:
|
|
return True
|
|
window.append(now)
|
|
return False
|
|
|
|
|
|
def install(app) -> None:
|
|
"""Podpina ochronę pod wszystkie ścieżki poza /health i /static."""
|
|
if not auth_enabled():
|
|
log.warning(
|
|
"UWAGA: APP_PASSWORD nie ustawione — aplikacja jest OTWARTA dla każdego, "
|
|
"kto ma do niej dostęp sieciowy, wraz z treścią baz interpretacyjnych."
|
|
)
|
|
|
|
@app.middleware("http")
|
|
async def _guard(request: Request, call_next):
|
|
if _is_public(request.url.path):
|
|
return await call_next(request)
|
|
|
|
if _rate_limited(client_ip(request)):
|
|
return JSONResponse(
|
|
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
|
status_code=429, headers={"Retry-After": "60"},
|
|
)
|
|
|
|
if auth_enabled() and not _authorized(request.headers.get("authorization")):
|
|
return HTMLResponse(
|
|
"<h1>401 — wymagane logowanie</h1>", status_code=401,
|
|
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
|
|
)
|
|
return await call_next(request)
|