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
Vorgesehen als MINOR-Release **1.27.0** „Firewall-Oberfläche über OPNexus“. - **Firewall-Oberfläche über OPNexus:** Auf der Host-Seite öffnet **Oberfläche öffnen** die Weboberfläche der Firewall in einem neuen Tab, auch bei Firewalls hinter NAT im Management-Tunnel. Nur für Admins; jede Firewall läuft in einer eigenen Browser-Origin (Platz = Port), Cookies bleiben auf dem Server, das Ticket gilt einmalig 30 Sekunden, Inaktivitäts- und Gesamtlimit sind einstellbar, Ticket, Start und Ende stehen im Audit (Art „GUI-Zugriff“). Standardmäßig **aus**: braucht das Compose-Profil `gui`, `OPNEXUS_GUI_SITES`, `GUI_PORTS`, `GUI_GATEWAY_TOKEN`, `COOKIE_SECURE=true` und den Schalter unter Einstellungen (Handbuch, „Firewall-Oberfläche über OPNexus“). Live getestet auf OPNsense 26.7. - **Einschränkungen:** kein WebSocket, keine automatische Anmeldung, höchstens 4 Plätze gleichzeitig. OPNexus-Adresse vorher in die Whitelist des Anmeldeschutzes (sshguard) der Firewall eintragen, sonst können Fehlanmeldungen die API-Zugriffe von OPNexus aussperren. Nach einem Neustart des wg-hub braucht der Tunnel 2 bis 4 Minuten. - **Hinweis für Betreiber mit eigenen Skripten:** Bei HTTPS heißt das Sitzungs-Cookie jetzt `__Host-opnexus-session` (bestehende Sitzungen werden ohne Abmelden übernommen), und Cookie-Anfragen an `/api` mit `Sec-Fetch-Site: same-site/cross-site` werden abgelehnt. Zugriffe mit API-Token sind nicht betroffen.
138 lines
6.4 KiB
Python
138 lines
6.4 KiB
Python
"""1.27 prerequisite hardening: __Host- session cookie (with soft migration) and
|
|
the Sec-Fetch-Site check for cookie-authenticated /api requests."""
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app import main
|
|
from app.config import settings
|
|
from app.main import app
|
|
from app.routers.auth import COOKIE_NAME, SECURE_COOKIE_NAME
|
|
|
|
PW = "tokpass-12345678"
|
|
|
|
|
|
def _https_client() -> AsyncClient:
|
|
return AsyncClient(transport=ASGITransport(app=app), base_url="https://test")
|
|
|
|
|
|
async def _login(ac: AsyncClient) -> None:
|
|
resp = await ac.post("/api/auth/login", json={"username": settings.admin_username, "password": settings.admin_password})
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
def _set_cookies(resp) -> list[str]:
|
|
return resp.headers.get_list("set-cookie")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_secure_login_sets_host_prefixed_cookie_and_drops_legacy(monkeypatch):
|
|
monkeypatch.setattr(settings, "cookie_secure", True)
|
|
async with _https_client() as ac:
|
|
resp = await ac.post("/api/auth/login", json={"username": settings.admin_username, "password": settings.admin_password})
|
|
assert resp.status_code == 200
|
|
cookies = _set_cookies(resp)
|
|
new = [c for c in cookies if c.startswith(f"{SECURE_COOKIE_NAME}=")]
|
|
assert new and "Secure" in new[0] and "Path=/" in new[0] and "HttpOnly" in new[0] and "Domain" not in new[0]
|
|
assert any(c.startswith(f'{COOKIE_NAME}=""') or c.startswith(f"{COOKIE_NAME}=;") or "Max-Age=0" in c
|
|
for c in cookies if c.startswith(f"{COOKIE_NAME}="))
|
|
assert (await ac.get("/api/hosts")).status_code == 200
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plain_name_cookie_without_https():
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
|
resp = await ac.post("/api/auth/login", json={"username": settings.admin_username, "password": settings.admin_password})
|
|
assert any(c.startswith(f"{COOKIE_NAME}=") for c in _set_cookies(resp))
|
|
assert not any(c.startswith(SECURE_COOKIE_NAME) for c in _set_cookies(resp))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_legacy_cookie_is_migrated_not_logged_out(monkeypatch):
|
|
# A session issued before the upgrade (plain cookie name) ...
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
|
await _login(ac)
|
|
legacy = ac.cookies.get(COOKIE_NAME)
|
|
assert legacy
|
|
monkeypatch.setattr(settings, "cookie_secure", True)
|
|
async with _https_client() as ac:
|
|
ac.cookies.set(COOKIE_NAME, legacy)
|
|
assert (await ac.get("/api/hosts")).status_code == 200 # still accepted
|
|
me = await ac.get("/api/auth/me")
|
|
assert me.json()["authenticated"] is True
|
|
moved = [c for c in _set_cookies(me) if c.startswith(f"{SECURE_COOKIE_NAME}={legacy}")]
|
|
assert moved and "Secure" in moved[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_host_cookie_wins_over_tossed_plain_cookie(monkeypatch):
|
|
monkeypatch.setattr(settings, "cookie_secure", True)
|
|
async with _https_client() as ac:
|
|
await _login(ac)
|
|
real = ac.cookies.get(SECURE_COOKIE_NAME)
|
|
async with _https_client() as ac:
|
|
# A script on another port may set a plain-name cookie; it is ignored ...
|
|
ac.cookies.set(SECURE_COOKIE_NAME, real)
|
|
ac.cookies.set(COOKIE_NAME, "attacker-token")
|
|
assert (await ac.get("/api/hosts")).status_code == 200
|
|
async with _https_client() as ac:
|
|
# ... and a bad __Host- cookie never falls back to the plain one.
|
|
ac.cookies.set(SECURE_COOKIE_NAME, "garbage")
|
|
ac.cookies.set(COOKIE_NAME, real)
|
|
assert (await ac.get("/api/hosts")).status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("site,expected", [
|
|
("same-site", 403), ("cross-site", 403), ("Cross-Site", 403),
|
|
("same-origin", 200), ("none", 200), (None, 200),
|
|
])
|
|
async def test_sec_fetch_site_on_cookie_requests(client, site, expected):
|
|
headers = {"Sec-Fetch-Site": site} if site else {}
|
|
resp = await client.get("/api/hosts", headers=headers)
|
|
assert resp.status_code == expected, resp.text
|
|
if expected == 403:
|
|
assert resp.json()["detail"]["code"] == "CROSS_SITE_REQUEST_BLOCKED"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sec_fetch_same_site_allowed_only_for_configured_frontend(client, monkeypatch):
|
|
monkeypatch.setattr(settings, "frontend_origin", "https://opnexus.example:3100")
|
|
ok = await client.get("/api/hosts", headers={"Sec-Fetch-Site": "same-site", "Origin": "https://opnexus.example:3100"})
|
|
assert ok.status_code == 200
|
|
slot = await client.get("/api/hosts", headers={"Sec-Fetch-Site": "same-site", "Origin": "https://opnexus.example:9441"})
|
|
assert slot.status_code == 403
|
|
# Writes from a slot page are refused as well (before auth even runs).
|
|
write = await client.post("/api/auth/logout", headers={"Sec-Fetch-Site": "same-site", "Origin": "https://opnexus.example:9441"})
|
|
assert write.status_code == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sec_fetch_does_not_touch_token_requests(client, anon_client):
|
|
created = (await client.post("/api/tokens", json={"name": "sfs", "scope": "read", "current_password": settings.admin_password})).json()
|
|
resp = await anon_client.get("/api/hosts", headers={"Authorization": f"Bearer {created['token']}", "Sec-Fetch-Site": "cross-site"})
|
|
assert resp.status_code == 200
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sec_fetch_exempts_sso_callback(client):
|
|
resp = await client.get("/api/auth/oidc/callback?code=x&state=y", headers={"Sec-Fetch-Site": "cross-site"}, follow_redirects=False)
|
|
assert resp.status_code != 403 or resp.json().get("detail", {}).get("code") != "CROSS_SITE_REQUEST_BLOCKED"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_startup_refused_when_slot_origin_is_a_frontend_origin(monkeypatch):
|
|
monkeypatch.setattr(settings, "frontend_origin", "https://opnexus.example, https://opnexus.example:9441")
|
|
monkeypatch.setattr(settings, "opnexus_gui_sites", "https://opnexus.example:9441,https://opnexus.example:9442")
|
|
assert settings.gui_origin_conflicts() == ["https://opnexus.example:9441"]
|
|
called = []
|
|
|
|
async def _no_migrate():
|
|
called.append(1)
|
|
monkeypatch.setattr(main.migrate, "run", _no_migrate)
|
|
with pytest.raises(RuntimeError):
|
|
async with main.lifespan(app):
|
|
pass
|
|
assert not called
|
|
monkeypatch.setattr(settings, "opnexus_gui_sites", "https://opnexus.example:9442")
|
|
assert settings.gui_origin_conflicts() == []
|