Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Automatisierung und Sicherheitslage. Ende-zu-Ende gegen einen Test-Stack und drei Test-Firewalls geprüft, siehe Handbuch „Sicherheitslage“ und „Automatisierung“. - **API-Tokens** (System › API-Tokens): persönliche Tokens für Skripte, CI, Prometheus und KI-Assistenten, Bereiche *Lesen*, *Ausrollen*, *Admin*. Nur als Hash gespeichert, einmal angezeigt, widerrufbar, mit Ablaufdatum; Anlegen verlangt das Passwort. Ein Token überschreitet nie die Rechte seines Besitzers. Tokens verwalten und das eigene Konto geht nur in einer normalen Anmeldung. Geheimnisse (Roh-Backups, Benachrichtigungseinstellungen) liest nur der Bereich *Admin*. Das Audit nennt das Token. Neue Tabelle `api_tokens` (Migration `0008`). - **Prometheus-Metriken** unter `GET /api/metrics` (Host erreichbar, Last, Speicher, Platte, CARP-Rolle, offene Aktionen und Probleme, Sicherheitswert), nur aus der Datenbank, ohne Anfragen an die Firewalls. - **MCP-Endpunkt** `POST /api/mcp` (nur lesend) für KI-Assistenten: Flottenübersicht, Hosts, Auslastung, CARP-Ereignisse, Audit, Suche und ein Leser für jeden GET-Endpunkt, mit den Rechten des Tokens. - **OpenAPI-Schema** jetzt unter `/api/openapi.json` (bisher unter `/openapi.json`, vom Proxy nicht erreichbar); die Swagger-/ReDoc-Seiten entfallen (ihre CDN-Skripte verbietet die CSP). - **Sicherheitslage** (Monitoring › Sicherheitslage): Härtungs- und Regel-Hygiene-Prüfungen je Firewall mit Wert 0–100: Allow-all am WAN, HTTPS, SSH, SNMP, verdeckte/doppelte/unbeschriftete Regeln, Zeitserver, externe Logs, Firmware, Backup-Alter. Liest die Konfiguration nur lesend, speichert sie nicht, alle 6 Stunden und auf Knopfdruck. Neue Tabelle `compliance_results` (Migration `0009`). - Behoben: Änderungen am Benutzer in einer Anfrage (z. B. 2FA aktivieren) gingen verloren, wenn die Middleware den Benutzer aus einer anderen Datenbank-Sitzung lieferte; er wird jetzt in der Sitzung der Anfrage neu geladen.
107 lines
3.7 KiB
Python
107 lines
3.7 KiB
Python
import pytest
|
|
from starlette.requests import Request
|
|
|
|
from app.config import settings
|
|
from app.routers.auth import client_ip
|
|
|
|
|
|
def _make_request(headers: dict, client_host: str = "9.9.9.9") -> Request:
|
|
raw_headers = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
|
|
return Request({"type": "http", "headers": raw_headers, "client": (client_host, 12345)})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_success(anon_client):
|
|
resp = await anon_client.post(
|
|
"/api/auth/login",
|
|
json={"username": settings.admin_username, "password": settings.admin_password},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["authenticated"] is True
|
|
assert "opnexus-session" in resp.cookies
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_wrong_password(anon_client):
|
|
resp = await anon_client.post(
|
|
"/api/auth/login",
|
|
json={"username": settings.admin_username, "password": "definitely-wrong"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_lockout_after_repeated_failures(anon_client):
|
|
for _ in range(settings.login_max_attempts):
|
|
resp = await anon_client.post(
|
|
"/api/auth/login",
|
|
json={"username": settings.admin_username, "password": "wrong"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
resp = await anon_client.post(
|
|
"/api/auth/login",
|
|
json={"username": settings.admin_username, "password": settings.admin_password},
|
|
)
|
|
assert resp.status_code == 429
|
|
|
|
|
|
def test_client_ip_ignores_forwarded_header_by_default(monkeypatch):
|
|
monkeypatch.setattr(settings, "trust_proxy_headers", False)
|
|
req = _make_request({"x-forwarded-for": "1.2.3.4"}, client_host="9.9.9.9")
|
|
assert client_ip(req) == "9.9.9.9"
|
|
|
|
|
|
def test_client_ip_uses_forwarded_header_when_trusted(monkeypatch):
|
|
monkeypatch.setattr(settings, "trust_proxy_headers", True)
|
|
req = _make_request({"x-forwarded-for": "1.2.3.4, 10.0.0.5"}, client_host="10.0.0.5")
|
|
assert client_ip(req) == "1.2.3.4"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unauthenticated_request_rejected(anon_client):
|
|
resp = await anon_client.get("/api/hosts")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_me_reports_authenticated_state(anon_client, client):
|
|
resp = await anon_client.get("/api/auth/me")
|
|
assert resp.json()["authenticated"] is False
|
|
|
|
resp = await client.get("/api/auth/me")
|
|
assert resp.json() == {"authenticated": True, "username": settings.admin_username, "role": "admin"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_logout_invalidates_session(client):
|
|
resp = await client.get("/api/hosts")
|
|
assert resp.status_code == 200
|
|
|
|
await client.post("/api/auth/logout")
|
|
|
|
resp = await client.get("/api/hosts")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_stays_public_but_docs_require_auth(anon_client):
|
|
resp = await anon_client.get("/api/health")
|
|
assert resp.status_code == 200
|
|
|
|
# /docs/redoc/openapi.json used to be public — now require a session
|
|
# like any other endpoint, since fully documenting a firewall-fleet
|
|
# API surface to an anonymous caller is free recon for an attacker.
|
|
for path in ("/docs", "/redoc", "/openapi.json", "/api/openapi.json"):
|
|
resp = await anon_client.get(path)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openapi_schema_reachable_once_logged_in(client):
|
|
# The schema lives under /api (the proxy only routes /api to the backend); the
|
|
# interactive Swagger/ReDoc pages are gone (their CDN scripts violate the CSP).
|
|
assert (await client.get("/api/openapi.json")).status_code == 200
|
|
for path in ("/docs", "/redoc", "/openapi.json"):
|
|
assert (await client.get(path)).status_code == 404
|