diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index eea5bd5..29d6d71 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -51,6 +51,26 @@ jobs: EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris run: pytest tests -q -rs + presentation-tests: + name: Testy warstwy prezentacji (dostęp do baz) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: services/presentation/requirements-dev.txt + - name: Instalacja zależności + run: pip install -r services/presentation/requirements-dev.txt + # Bramka chroniąca oryginalne bazy — nietestowany kod ochronny jest gorszy + # niż jego brak, bo daje złudzenie zabezpieczenia. + - name: Testy (pytest) + working-directory: services/presentation + env: + PYTHONPATH: . + run: pytest tests -q -rs + swisseph-image: name: Build obrazu silnika B (swisseph) runs-on: ubuntu-latest diff --git a/services/data/app/main.py b/services/data/app/main.py index 3c543c9..6184dd8 100644 --- a/services/data/app/main.py +++ b/services/data/app/main.py @@ -10,6 +10,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from app import security from app.config import settings from app.models import HealthInfo, SearchQuery, SearchResult from app.providers.factory import build_provider @@ -24,6 +25,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan) +security.install(app, "danych") # token międzywarstwowy (LOG-32) @app.post("/search", response_model=SearchResult) diff --git a/services/data/app/models.py b/services/data/app/models.py index 3477c1f..ec0cdfb 100644 --- a/services/data/app/models.py +++ b/services/data/app/models.py @@ -18,7 +18,10 @@ class SearchQuery(BaseModel): key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.") value: str = Field(..., description="Szukana wartość.") exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).") - limit: int = Field(50, ge=1, le=50000) + # Górny limit celowo niski: to zapytanie oddaje SUROWE wiersze baz, więc wysoki + # pułap zamienia je w narzędzie do masowego pobrania (LOG-32). 5000 = tyle, ile + # realnie potrzebuje build_report na jeden obiekt. + limit: int = Field(50, ge=1, le=5000) fields: list[str] | None = Field( None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie." ) diff --git a/services/data/app/security.py b/services/data/app/security.py new file mode 100644 index 0000000..f755ca9 --- /dev/null +++ b/services/data/app/security.py @@ -0,0 +1,50 @@ +"""Uwierzytelnianie międzywarstwowe (LOG-32). + +Warstwa danych oddaje SUROWE wiersze baz — to najbardziej wrażliwy punkt całego +systemu. Bez tego kontrolera wystarczyłoby uderzyć w nią bezpośrednio, z pominięciem +i logiki, i logowania w UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health) +musi go przynieść w nagłówku X-Astrololo-Token. + +Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy +starcie leci ostrzeżenie. +""" +from __future__ import annotations + +import logging +import os +import secrets + +from fastapi import Request +from fastapi.responses import JSONResponse + +log = logging.getLogger("astrololo.security") + +HEADER = "X-Astrololo-Token" +PUBLIC_PATHS = frozenset({"/health"}) + + +def token() -> str: + """Czytany leniwie — konfiguracja może się zmienić bez importu modułu.""" + return os.getenv("INTERNAL_TOKEN", "") + + +def enabled() -> bool: + return bool(token()) + + +def install(app, layer: str) -> None: + if not enabled(): + log.warning( + "UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od " + "kogokolwiek, kto ma do niej dostęp sieciowy.", layer, + ) + + @app.middleware("http") + async def _guard(request: Request, call_next): + if request.url.path in PUBLIC_PATHS or not enabled(): + return await call_next(request) + got = request.headers.get(HEADER, "") + if not secrets.compare_digest(got, token()): + return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."}, + status_code=401) + return await call_next(request) diff --git a/services/logic/app/clients/data_client.py b/services/logic/app/clients/data_client.py index 5fd9dc3..9895ba6 100644 --- a/services/logic/app/clients/data_client.py +++ b/services/logic/app/clients/data_client.py @@ -5,6 +5,7 @@ Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację """ 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 DataClient: def __init__(self, base_url: str | None = None) -> None: self.base_url = (base_url or settings.data_url).rstrip("/") @@ -26,12 +33,12 @@ class DataClient: ) -> dict[str, Any]: payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields} with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client: - r = client.post(f"{self.base_url}/search", json=payload) + r = client.post(f"{self.base_url}/search", json=payload, headers=_auth_headers()) r.raise_for_status() return r.json() def health(self) -> dict[str, Any]: with httpx.Client(timeout=settings.http_timeout) as client: - r = client.get(f"{self.base_url}/health") + r = client.get(f"{self.base_url}/health", headers=_auth_headers()) r.raise_for_status() return r.json() diff --git a/services/logic/app/main.py b/services/logic/app/main.py index b32c04f..3a68682 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -12,12 +12,14 @@ import httpx from fastapi import FastAPI, HTTPException from pydantic import BaseModel +from app import security from app.clients.data_client import DataClient from app.models import QueryRequest, QueryResponse from app.service import QueryService app = FastAPI(title="astrololo · warstwa logiczna") service = QueryService() +security.install(app, "logiczna") # token międzywarstwowy (LOG-32) # --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu --- _engine = None diff --git a/services/logic/app/security.py b/services/logic/app/security.py new file mode 100644 index 0000000..618df42 --- /dev/null +++ b/services/logic/app/security.py @@ -0,0 +1,50 @@ +"""Uwierzytelnianie międzywarstwowe (LOG-32). + +Warstwa logiczna oddaje treść baz interpretacyjnych, więc samo zalogowanie w +prezentacji nie wystarczy — bez tego kontrolera wystarczyłoby uderzyć w logikę +z pominięciem UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health) +musi go przynieść w nagłówku X-Astrololo-Token. + +Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy +starcie leci ostrzeżenie. +""" +from __future__ import annotations + +import logging +import os +import secrets + +from fastapi import Request +from fastapi.responses import JSONResponse + +log = logging.getLogger("astrololo.security") + +HEADER = "X-Astrololo-Token" +PUBLIC_PATHS = frozenset({"/health"}) + + +def token() -> str: + """Czytany leniwie — konfiguracja może się zmienić bez importu modułu.""" + return os.getenv("INTERNAL_TOKEN", "") + + +def enabled() -> bool: + return bool(token()) + + +def install(app, layer: str) -> None: + if not enabled(): + log.warning( + "UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od " + "kogokolwiek, kto ma do niej dostęp sieciowy.", layer, + ) + + @app.middleware("http") + async def _guard(request: Request, call_next): + if request.url.path in PUBLIC_PATHS or not enabled(): + return await call_next(request) + got = request.headers.get(HEADER, "") + if not secrets.compare_digest(got, token()): + return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."}, + status_code=401) + return await call_next(request) diff --git a/services/logic/tests/test_security.py b/services/logic/tests/test_security.py new file mode 100644 index 0000000..5b28895 --- /dev/null +++ b/services/logic/tests/test_security.py @@ -0,0 +1,65 @@ +"""Token międzywarstwowy (LOG-32). + +Warstwa logiczna oddaje treść baz, więc musi odrzucać żądania z pominięciem UI. +""" +import pytest +from fastapi import FastAPI +from starlette.testclient import TestClient + +from app import security + +TOKEN = "tajny-token-testowy" + + +def _app() -> FastAPI: + app = FastAPI() + security.install(app, "testowa") + + @app.get("/health") + def health(): + return {"status": "ok"} + + @app.get("/secret") + def secret(): + return {"rows": ["treść z bazy"]} + + return app + + +@pytest.fixture +def guarded(monkeypatch): + monkeypatch.setenv("INTERNAL_TOKEN", TOKEN) + return TestClient(_app()) + + +@pytest.fixture +def open_app(monkeypatch): + monkeypatch.delenv("INTERNAL_TOKEN", raising=False) + return TestClient(_app()) + + +def test_rejects_request_without_token(guarded): + assert guarded.get("/secret").status_code == 401 + + +def test_rejects_wrong_token(guarded): + r = guarded.get("/secret", headers={security.HEADER: "zly"}) + assert r.status_code == 401 + assert "treść z bazy" not in r.text + + +def test_accepts_correct_token(guarded): + r = guarded.get("/secret", headers={security.HEADER: TOKEN}) + assert r.status_code == 200 + assert r.json()["rows"] == ["treść z bazy"] + + +def test_health_stays_public(guarded): + """Sonda k8s nie zna tokenu — /health musi działać bez niego.""" + assert guarded.get("/health").status_code == 200 + + +def test_disabled_when_token_unset(open_app): + """Brak konfiguracji = zgodność wstecz (dev), nie blokada.""" + assert not security.enabled() + assert open_app.get("/secret").status_code == 200 diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py index 4ed257e..a225cdb 100644 --- a/services/presentation/app/clients/logic_client.py +++ b/services/presentation/app/clients/logic_client.py @@ -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() diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 637f5b1..46893e6 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -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]: diff --git a/services/presentation/app/security.py b/services/presentation/app/security.py new file mode 100644 index 0000000..de8577a --- /dev/null +++ b/services/presentation/app/security.py @@ -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( + "
treść z bazy interpretacyjnej
" + + 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))