Server Up beheerde de Docker-daemon als root zonder enige vorm van authenticatie: elke /api/*-route was gelijk aan root-toegang op de host. - Authenticatie toegevoegd (core/auth.py): lokale accounts met scrypt-hash, sessiecookie (HttpOnly, SameSite=Strict) en optionele trusted-proxy-header SSO die alleen vanaf geconfigureerde proxy-IP's wordt vertrouwd. - before_request-guard schermt alle API-routes af; loginscherm en eerste-account-setup in de UI. - CSRF-token verplicht op elke mutatie; GET-varianten van state-wijzigende routes verwijderd (o.a. /api/docker/restart was via <img> te triggeren). - Path traversal in /api/store/install gedicht; gedeelde safe_name()-validatie voor stack-, instantie- en repo-namen. - Git-tokens worden niet meer teruggegeven via /api/repos en /api/settings (has_token-vlag); settings-PUT wist een bestaand token niet meer en kan AUTH niet overschrijven. - Git-URL's beperkt tot http(s)/ssh/scp-syntax; ext::-transport (voert een shell-commando uit) en file:// worden geweigerd. - Boilerplate-templates renderen in een SandboxedEnvironment (SSTI). - Automatisch syncen van repo's bij boot standaard uit (AUTO_SYNC_ON_BOOT), optionele commit-pinning per repo. - Waitress in plaats van de Flask-ontwikkelserver, MAX_CONTENT_LENGTH, ProxyFix, en CSP/X-Frame-Options/nosniff/Referrer-Policy headers. - Front-end libraries (Tailwind, Alpine, htmx) lokaal meegeleverd i.p.v. CDN; Google Fonts verwijderd. Werkt nu ook offline. - SSH host-key-verificatie aan (accept-new + /data/known_hosts). - Lichte /healthz voor de healthcheck i.p.v. `docker info`. - config.json en secret.key met 0600-rechten. - Poort standaard op 127.0.0.1 gebonden. - Audit-log gebruikt één gedeelde SQLite-verbinding (fd-lek per job verholpen); joblogs afgekapt op 2000 regels. - VERSION-bestand is de enige bron voor het versienummer. - pytest-suite toegevoegd (74 tests) en als stap in beide deploy-workflows. - fix-config.sh verwijderd (hardgecodeerd intern IP, overschreef config). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
121 lines
4.1 KiB
Python
121 lines
4.1 KiB
Python
"""Authenticatie: guard, CSRF, lockout en de trusted-proxy-header."""
|
|
import pytest
|
|
|
|
from conftest import login
|
|
|
|
|
|
def test_api_vereist_login(anon_client):
|
|
r = anon_client.get("/api/stacks")
|
|
assert r.status_code == 401
|
|
assert r.get_json()["needs_setup"] is True
|
|
|
|
|
|
def test_index_en_healthz_zijn_publiek(anon_client):
|
|
assert anon_client.get("/healthz").status_code == 200
|
|
assert anon_client.get("/").status_code == 200
|
|
|
|
|
|
def test_setup_maakt_account_en_kan_niet_herhaald_worden(anon_client):
|
|
login(anon_client)
|
|
r = anon_client.post("/api/auth/setup",
|
|
json={"username": "tweede", "password": "hunter2hunter2"})
|
|
assert r.status_code == 409
|
|
|
|
|
|
def test_setup_weigert_kort_wachtwoord(anon_client):
|
|
r = anon_client.post("/api/auth/setup",
|
|
json={"username": "tester", "password": "kort"})
|
|
assert r.status_code == 400
|
|
assert "10 tekens" in r.get_json()["msg"]
|
|
|
|
|
|
def test_login_en_logout(client):
|
|
login(client)
|
|
client.post("/api/auth/logout", json={},
|
|
headers={"X-CSRF-Token": _csrf(client)})
|
|
assert client.get("/api/stacks").status_code == 401
|
|
|
|
r = client.post("/api/auth/login",
|
|
json={"username": "tester", "password": "hunter2hunter2"})
|
|
assert r.status_code == 200
|
|
assert client.get("/api/stacks").status_code == 200
|
|
|
|
|
|
def test_verkeerd_wachtwoord_wordt_geweigerd(client):
|
|
login(client)
|
|
client.post("/api/auth/logout", json={}, headers={"X-CSRF-Token": _csrf(client)})
|
|
r = client.post("/api/auth/login",
|
|
json={"username": "tester", "password": "verkeerdverkeerd"})
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_lockout_na_herhaalde_pogingen(client):
|
|
import core.auth as auth
|
|
login(client)
|
|
for _ in range(5):
|
|
auth.check_login("tester", "foutfoutfout")
|
|
ok, msg = auth.check_login("tester", "hunter2hunter2")
|
|
assert not ok
|
|
assert "Te veel mislukte pogingen" in msg
|
|
|
|
|
|
def test_mutatie_zonder_csrf_token_wordt_geweigerd(client):
|
|
login(client)
|
|
r = client.post("/api/docker/restart", json={})
|
|
assert r.status_code == 403
|
|
assert "CSRF" in r.get_json()["msg"]
|
|
|
|
|
|
def test_mutatie_met_verkeerd_csrf_token_wordt_geweigerd(client):
|
|
login(client)
|
|
r = client.post("/api/docker/restart", json={},
|
|
headers={"X-CSRF-Token": "niet-het-echte-token"})
|
|
assert r.status_code == 403
|
|
|
|
|
|
def test_state_wijzigende_routes_weigeren_get(client):
|
|
"""Zonder deze regel volstond <img src=".../api/docker/restart"> op een
|
|
willekeurige website om de container te herstarten."""
|
|
login(client)
|
|
for path in ("/api/docker/restart", "/api/wizard/complete",
|
|
"/api/wizard/reset", "/api/wizard/sync",
|
|
"/api/repos/server-up/sync"):
|
|
assert client.get(path).status_code == 405, path
|
|
|
|
|
|
def test_proxy_header_alleen_vanaf_vertrouwd_ip(client, env):
|
|
core = env["core"]
|
|
import core.auth as auth
|
|
a = auth.settings()
|
|
a.update({"mode": "both", "proxy_header": "Remote-User",
|
|
"trusted_proxies": ["10.9.9.9"]})
|
|
core.patch({"AUTH": a})
|
|
|
|
# Niet-vertrouwd bron-IP: header wordt genegeerd.
|
|
r = client.get("/api/stacks", headers={"Remote-User": "indringer"},
|
|
environ_overrides={"REMOTE_ADDR": "10.1.1.1"})
|
|
assert r.status_code == 401
|
|
|
|
# Vertrouwde proxy: identiteit wordt geaccepteerd.
|
|
r = client.get("/api/stacks", headers={"Remote-User": "collega"},
|
|
environ_overrides={"REMOTE_ADDR": "10.9.9.9"})
|
|
assert r.status_code == 200
|
|
|
|
|
|
def test_proxy_modus_vereist_trusted_proxies(client):
|
|
csrf = login(client)
|
|
r = client.put("/api/auth/mode", json={"mode": "proxy", "trusted_proxies": []},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_wachtwoordhash_is_niet_omkeerbaar():
|
|
import core.auth as auth
|
|
rec = auth.hash_password("hunter2hunter2")
|
|
assert "hunter2hunter2" not in str(rec)
|
|
assert auth.verify_password("hunter2hunter2", rec)
|
|
assert not auth.verify_password("hunter2hunter3", rec)
|
|
|
|
|
|
def _csrf(client):
|
|
return client.get("/api/auth/me").get_json()["csrf_token"]
|