feat(security): zamkniecie dostepu do baz interpretacyjnych (LOG-32)
build / build (push) Successful in 1m16s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m9s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m51s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 26s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s
build / build (push) Successful in 1m16s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m9s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m51s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 26s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s
Bazy sa rdzeniem produktu i wlasnie zostaly kupione — a aplikacja nie miala ZADNEGO uwierzytelniania. Prezentacja to NodePort, wiec kazdy w LAN wchodzil bez logowania, a `/search` oddawal surowe wiersze do 50 000 na zapytanie. Bazy mogly wyjsc przez sama aplikacje, bez udzialu jakiegokolwiek LLM. - prezentacja: HTTP Basic (APP_USER/APP_PASSWORD) + limit zadan na IP (RATE_LIMIT_PER_MIN, domyslnie 120/min). Limit dziala TAKZE przed uwierzytelnieniem, zeby zgadywanie hasla i sondowanie API nie bylo darmowe. - logika i dane: token miedzywarstwowy X-Astrololo-Token (INTERNAL_TOKEN) — bez niego dalo sie ominac logowanie, uderzajac wprost w warstwe nizej. Warstwa danych oddaje surowe wiersze, wiec to najwrazliwszy punkt. - /search: gorny limit 50 000 -> 5000 (tyle realnie uzywa build_report). Publiczne /api/query zostaje na 200. - /health celowo publiczny (sondy k8s go nie uwierzytelnia). - swiadomie nie logujemy tresci zadan ani promptow — logi to kolejny nosnik. Fail-open przy braku konfiguracji (zgodnosc wstecz i dev), ale z GLOSNYM ostrzezeniem przy starcie, zeby nikt nie wdrozyl tego w przekonaniu, ze jest chroniony. Wlaczenie w produkcji wymaga ustawienia sekretow w repo deploy. Testy: 12 (prezentacja, nowy katalog + job w CI) i 5 (logika). Zweryfikowane na zywym stosie: bez hasla 401, z haslem 200, logika wprost bez tokenu 401, z tokenem 200, /health 200, limit 50000 odrzucony (422), a prezentacja nadal liczy horoskop przez logike. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #10.
This commit is contained in:
@@ -5,6 +5,7 @@ opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy ani do silnika.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -12,6 +13,12 @@ import httpx
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _auth_headers() -> dict[str, str]:
|
||||
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
|
||||
token = os.getenv("INTERNAL_TOKEN", "")
|
||||
return {"X-Astrololo-Token": token} if token else {}
|
||||
|
||||
|
||||
class LogicClient:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
||||
@@ -19,7 +26,7 @@ class LogicClient:
|
||||
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
||||
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.post(f"{self.base_url}/api/query", json=payload)
|
||||
r = client.post(f"{self.base_url}/api/query", json=payload, headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@@ -45,7 +52,7 @@ class LogicClient:
|
||||
}
|
||||
# stacje wymagają root-findów — dłuższy timeout
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client:
|
||||
r = client.post(f"{self.base_url}/chart/positions", json=payload)
|
||||
r = client.post(f"{self.base_url}/chart/positions", json=payload, headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@@ -55,7 +62,7 @@ class LogicClient:
|
||||
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
|
||||
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
||||
r = client.post(f"{self.base_url}/chart/report", json=payload)
|
||||
r = client.post(f"{self.base_url}/chart/report", json=payload, headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@@ -85,6 +92,6 @@ class LogicClient:
|
||||
"from_date": from_date, "to_date": to_date, "interpret": interpret,
|
||||
}
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
|
||||
r = client.post(f"{self.base_url}/chart/timeline", json=payload)
|
||||
r = client.post(f"{self.base_url}/chart/timeline", json=payload, headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@@ -17,7 +17,7 @@ from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app import geocode
|
||||
from app import geocode, security
|
||||
from app.clients.logic_client import LogicClient
|
||||
from app.config import DEFAULT_LOCATION_LABEL, default_form
|
||||
|
||||
@@ -25,6 +25,7 @@ app = FastAPI(title="astrololo · warstwa prezentacji")
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
logic = LogicClient()
|
||||
security.install(app) # logowanie + limit żądań (LOG-32)
|
||||
|
||||
|
||||
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""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ń).
|
||||
|
||||
Ś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"))
|
||||
|
||||
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 _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)
|
||||
|
||||
client = request.client.host if request.client else "?"
|
||||
if _rate_limited(client):
|
||||
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)
|
||||
Reference in New Issue
Block a user