Sicherheits-Härtung und Bugfixes
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
import datetime
|
||||
import os
|
||||
|
||||
from conftest import TEST_PASSWORD, basic
|
||||
from models import get_session, Job, Recording, LogEntry, utcnow
|
||||
|
||||
|
||||
def _job(**kw):
|
||||
data = {"name": "Testjob", "stream_url": "https://example.com/stream.mp3"}
|
||||
data.update(kw)
|
||||
return data
|
||||
|
||||
|
||||
# --- Config ---
|
||||
|
||||
def test_config_get_never_leaks_secrets(client):
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
text = r.text
|
||||
for secret in ("tk_secret", "plik_secret", "pbkdf2", TEST_PASSWORD):
|
||||
assert secret not in text
|
||||
assert body["auth"]["password_set"] is True
|
||||
assert body["ntfy"]["token_set"] is True
|
||||
assert body["plik"]["api_key_set"] is True
|
||||
assert "password" not in body["auth"] and "password_hash" not in body["auth"]
|
||||
assert "token" not in body["ntfy"] and "api_key" not in body["plik"]
|
||||
assert set(body) == {"recording", "server", "auth", "ntfy", "plik", "schedule", "logging"}
|
||||
|
||||
|
||||
def test_config_put_forbidden_download_path(client):
|
||||
for path in ("/etc", "relative/path", os.environ["RECORDING_ROOTS"] + "/../escape"):
|
||||
r = client.put("/api/config", json={"data": {"recording": {"download_path": path}}})
|
||||
assert r.status_code == 400, path
|
||||
|
||||
|
||||
def test_config_put_allowed_download_path(client, app_mod):
|
||||
sub = os.path.join(os.environ["RECORDING_ROOTS"], "sub")
|
||||
r = client.put("/api/config", json={"data": {"recording": {"download_path": sub}}})
|
||||
assert r.status_code == 200, r.text
|
||||
assert app_mod.config.get("recording", "download_path") == os.path.realpath(sub)
|
||||
|
||||
|
||||
def test_config_put_rejects_server_and_unknown(client):
|
||||
assert client.put("/api/config", json={"data": {"server": {"port": 1}}}).status_code == 422
|
||||
assert client.put("/api/config", json={"data": {"auth": {"foo": 1}}}).status_code == 422
|
||||
assert client.put("/api/config", json={"data": {"recording": {"max_retries": 5000}}}).status_code == 422
|
||||
assert client.put("/api/config", json={"data": {"logging": {"level": "TRACE"}}}).status_code == 422
|
||||
|
||||
|
||||
def test_config_put_semantic_validation(client):
|
||||
assert client.put("/api/config", json={"data": {"ntfy": {"events": "start,foo"}}}).status_code == 400
|
||||
assert client.put("/api/config", json={"data": {"ntfy": {"url": "file:///x"}}}).status_code == 400
|
||||
assert client.put("/api/config", json={"data": {"plik": {"ttl": "30x"}}}).status_code == 400
|
||||
assert client.put("/api/config", json={"data": {"plik": {"ttl": "12h"}}}).status_code == 200
|
||||
|
||||
|
||||
def test_config_secrets_semantics(client, app_mod):
|
||||
cfg = app_mod.config
|
||||
# fehlt -> unverändert
|
||||
assert client.put("/api/config", json={"data": {"ntfy": {"url": "https://n.example/t"}}}).status_code == 200
|
||||
assert cfg.get("ntfy", "token") == "tk_secret"
|
||||
# leer -> löschen
|
||||
assert client.put("/api/config", json={"data": {"ntfy": {"token": ""}, "plik": {"api_key": ""}}}).status_code == 200
|
||||
assert cfg.get("ntfy", "token") == "" and cfg.get("plik", "api_key") == ""
|
||||
# Passwort leer -> 400, zu kurz -> 400
|
||||
assert client.put("/api/config", json={"data": {"auth": {"password": ""}}}).status_code == 400
|
||||
assert client.put("/api/config", json={"data": {"auth": {"password": "kurz"}}}).status_code == 400
|
||||
old_hash = cfg.get("auth", "password_hash")
|
||||
assert client.put("/api/config", json={"data": {"auth": {"password": "neues-passwort"}}}).status_code == 200
|
||||
assert cfg.get("auth", "password_hash") != old_hash
|
||||
assert "neues-passwort" not in open(cfg.config_path, encoding="utf-8").read()
|
||||
|
||||
|
||||
def test_enable_auth_without_password(client, app_mod):
|
||||
app_mod.config.data["auth"]["password_hash"] = ""
|
||||
r = client.put("/api/config", json={"data": {"auth": {"enabled": True}}})
|
||||
assert r.status_code == 400
|
||||
r = client.put("/api/config", json={"data": {"auth": {"enabled": True, "password": "langes-passwort"}}})
|
||||
assert r.status_code == 200
|
||||
assert client.get("/api/config").status_code == 401
|
||||
|
||||
|
||||
# --- Auth ---
|
||||
|
||||
def test_auth_middleware(client, app_mod):
|
||||
app_mod.config.data["auth"]["enabled"] = True
|
||||
assert client.get("/api/health").status_code == 200
|
||||
assert client.get("/api/health/x").status_code == 401
|
||||
r = client.get("/api/jobs")
|
||||
assert r.status_code == 401
|
||||
assert "Basic" in r.headers["www-authenticate"]
|
||||
assert client.get("/api/jobs", headers={"Authorization": basic("admin", TEST_PASSWORD)}).status_code == 200
|
||||
# zweiter Aufruf über Cache
|
||||
assert client.get("/api/jobs", headers={"Authorization": basic("admin", TEST_PASSWORD)}).status_code == 200
|
||||
assert client.get("/api/jobs", headers={"Authorization": "Basic !!!"}).status_code == 401
|
||||
assert client.get("/api/jobs", headers={"Authorization": basic("root", TEST_PASSWORD)}).status_code == 401
|
||||
|
||||
|
||||
def test_auth_bruteforce_429(client, app_mod):
|
||||
app_mod.config.data["auth"]["enabled"] = True
|
||||
for _ in range(10):
|
||||
assert client.get("/api/jobs", headers={"Authorization": basic("admin", "falsch")}).status_code == 401
|
||||
assert client.get("/api/jobs", headers={"Authorization": basic("admin", "falsch")}).status_code == 429
|
||||
assert client.get("/api/jobs", headers={"Authorization": basic("admin", TEST_PASSWORD)}).status_code == 429
|
||||
assert client.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
# --- CSRF / Header ---
|
||||
|
||||
def test_csrf(client):
|
||||
body = _job()
|
||||
assert client.post("/api/jobs", json=body, headers={"Sec-Fetch-Site": "cross-site"}).status_code == 403
|
||||
assert client.post("/api/jobs", json=body, headers={"Origin": "https://evil.example"}).status_code == 403
|
||||
assert client.post("/api/jobs", json=body, headers={"Origin": "null"}).status_code == 403
|
||||
r = client.post("/api/jobs", json=body, headers={"Origin": "http://testserver", "Sec-Fetch-Site": "same-origin"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert client.post("/api/jobs", json=body).status_code == 200
|
||||
# GET bleibt unberührt
|
||||
assert client.get("/api/jobs", headers={"Sec-Fetch-Site": "cross-site"}).status_code == 200
|
||||
|
||||
|
||||
def test_security_headers_and_docs(client):
|
||||
r = client.get("/api/health")
|
||||
assert r.headers["x-content-type-options"] == "nosniff"
|
||||
assert r.headers["x-frame-options"] == "DENY"
|
||||
assert r.headers["referrer-policy"] == "no-referrer"
|
||||
assert "frame-ancestors 'none'" in r.headers["content-security-policy"]
|
||||
for path in ("/docs", "/redoc", "/openapi.json"):
|
||||
assert client.get(path).status_code == 404
|
||||
|
||||
|
||||
# --- Jobs ---
|
||||
|
||||
def test_job_validation(client):
|
||||
assert client.post("/api/jobs", json=_job(stream_url="file:///etc/passwd")).status_code == 400
|
||||
assert client.post("/api/jobs", json=_job(stream_url="concat:a|b")).status_code == 400
|
||||
r = client.post("/api/jobs", json=_job(extra_ffmpeg_args="-c:a copy /app/app.py"))
|
||||
assert r.status_code == 400 and "/app/app.py" in r.json()["detail"]
|
||||
assert client.post("/api/jobs", json=_job(schedule_enabled=True)).status_code == 400
|
||||
assert client.post("/api/jobs", json=_job(schedule_start="24:00")).status_code == 400
|
||||
assert client.post("/api/jobs", json=_job(station_id=9999)).status_code == 400
|
||||
assert client.post("/api/jobs", json=_job(stream_type="evil")).status_code == 422
|
||||
assert client.post("/api/jobs", json=_job(name=" ")).status_code == 422
|
||||
assert client.post("/api/jobs", json=_job(segment_duration=5)).status_code == 422
|
||||
assert client.post("/api/jobs", json=_job(pre_buffer=500)).status_code == 422
|
||||
r = client.post("/api/jobs", json=_job(segment_duration=0, schedule_enabled=True, schedule_start="10:00",
|
||||
schedule_days="mo-fr", extra_ffmpeg_args="-c:a libmp3lame -b:a 128k"))
|
||||
assert r.status_code == 200, r.text
|
||||
job = client.get(f"/api/jobs/{r.json()['id']}").json()
|
||||
assert job["segment_duration"] is None
|
||||
|
||||
|
||||
def test_timestamps_are_utc_aware(client):
|
||||
job_id = client.post("/api/jobs", json=_job()).json()["id"]
|
||||
station_id = client.post("/api/stations", json={"name": "S", "stream_url": "https://e.example/s"}).json()["id"]
|
||||
session = get_session()
|
||||
try:
|
||||
session.add(Recording(job_id=None, job_name="x", stream_url="https://e.example", status="completed",
|
||||
stopped_at=utcnow()))
|
||||
session.add(LogEntry(level="INFO", message="m"))
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
stamps = [
|
||||
client.get(f"/api/jobs/{job_id}").json()["created_at"],
|
||||
client.get("/api/jobs").json()[0]["created_at"],
|
||||
client.get(f"/api/stations/{station_id}").json()["created_at"],
|
||||
client.get("/api/recordings").json()[0]["started_at"],
|
||||
client.get("/api/recordings").json()[0]["stopped_at"],
|
||||
client.get("/api/logs").json()[0]["timestamp"],
|
||||
]
|
||||
for s in stamps:
|
||||
assert s.endswith("+00:00"), s
|
||||
assert abs(datetime.datetime.fromisoformat(s) - datetime.datetime.now(datetime.timezone.utc)).total_seconds() < 120
|
||||
|
||||
|
||||
def test_delete_station_unlinks_jobs(client):
|
||||
sid = client.post("/api/stations", json={"name": "S", "stream_url": "https://e.example/s"}).json()["id"]
|
||||
jid = client.post("/api/jobs", json=_job(station_id=sid)).json()["id"]
|
||||
assert client.delete(f"/api/stations/{sid}").status_code == 200
|
||||
assert client.get(f"/api/jobs/{jid}").json()["station_id"] is None
|
||||
|
||||
|
||||
def _add_recording(**kw):
|
||||
session = get_session()
|
||||
try:
|
||||
rec = Recording(job_name="x", stream_url="https://e.example", **kw)
|
||||
session.add(rec)
|
||||
session.commit()
|
||||
return rec.id
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_delete_and_start_job_while_recording(client, app_mod, monkeypatch):
|
||||
jid = client.post("/api/jobs", json=_job()).json()["id"]
|
||||
_add_recording(job_id=jid, status="running")
|
||||
assert client.delete(f"/api/jobs/{jid}").status_code == 400
|
||||
called = []
|
||||
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: called.append(kw) or {})
|
||||
assert client.post(f"/api/jobs/{jid}/record").status_code == 409
|
||||
assert not called
|
||||
|
||||
|
||||
def test_start_job_legacy_bad_data(client, app_mod, monkeypatch):
|
||||
session = get_session()
|
||||
try:
|
||||
job = Job(name="alt", stream_url="https://e.example/s", schedule_start="10:00", schedule_stop="xx")
|
||||
session.add(job)
|
||||
session.commit()
|
||||
jid = job.id
|
||||
finally:
|
||||
session.close()
|
||||
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: {"recording_id": 1})
|
||||
assert client.post(f"/api/jobs/{jid}/record").status_code == 400
|
||||
|
||||
|
||||
def test_start_job_passes_duration(client, app_mod, monkeypatch):
|
||||
jid = client.post("/api/jobs", json=_job(schedule_start="10:00", schedule_stop="11:30",
|
||||
pre_buffer=5)).json()["id"]
|
||||
calls = []
|
||||
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: calls.append(kw) or {"recording_id": 1})
|
||||
assert client.post(f"/api/jobs/{jid}/record").status_code == 200
|
||||
assert calls[0]["max_duration"] == 5400
|
||||
|
||||
|
||||
def test_quick_record_validation(client, app_mod, monkeypatch):
|
||||
monkeypatch.setattr(app_mod.recorder, "start_recording", lambda **kw: {"recording_id": 1})
|
||||
assert client.post("/api/quick-record", json={"url": "file:///etc/passwd"}).status_code == 400
|
||||
assert client.post("/api/quick-record", json={"url": "https://e.example/s", "max_duration": 0}).status_code == 422
|
||||
assert client.post("/api/quick-record", json={"url": "https://e.example/s"}).status_code == 200
|
||||
|
||||
|
||||
# --- Recordings ---
|
||||
|
||||
def test_extend(client):
|
||||
unlimited = _add_recording(status="running")
|
||||
r = client.post(f"/api/recordings/{unlimited}/extend?minutes=10")
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "Aufnahme ist bereits unbegrenzt"
|
||||
|
||||
stop = utcnow() + datetime.timedelta(minutes=5)
|
||||
limited = _add_recording(status="running", scheduled_stop=stop)
|
||||
assert client.post(f"/api/recordings/{limited}/extend?minutes=10").status_code == 200
|
||||
rec = client.get(f"/api/recordings/{limited}").json()
|
||||
new_stop = datetime.datetime.fromisoformat(rec["scheduled_stop"]).replace(tzinfo=None)
|
||||
assert abs((new_stop - stop).total_seconds() - 600) < 1
|
||||
|
||||
assert client.post(f"/api/recordings/{limited}/extend?minutes=0").status_code == 422
|
||||
assert client.post(f"/api/recordings/{limited}/extend?minutes=1441").status_code == 422
|
||||
assert client.post("/api/recordings/99999/extend?minutes=5").status_code == 404
|
||||
|
||||
|
||||
def test_stop_endpoint(client):
|
||||
assert client.post("/api/recordings/99999/stop").status_code == 404
|
||||
done = _add_recording(status="completed")
|
||||
assert client.post(f"/api/recordings/{done}/stop").status_code == 400
|
||||
|
||||
|
||||
def test_query_limits(client):
|
||||
assert client.get("/api/logs?limit=0").status_code == 422
|
||||
assert client.get("/api/logs?limit=1001").status_code == 422
|
||||
assert client.get("/api/logs?level=bogus").status_code == 400
|
||||
assert client.get("/api/logs?level=warn").status_code == 200
|
||||
assert client.get("/api/recordings?limit=501").status_code == 422
|
||||
|
||||
|
||||
def test_cleanup_stale(client, app_mod):
|
||||
rid = _add_recording(status="running", pid=999999, output_file="/nonexistent/x.mp3")
|
||||
rid2 = _add_recording(status="starting")
|
||||
app_mod.recorder.cleanup_stale()
|
||||
session = get_session()
|
||||
try:
|
||||
for i in (rid, rid2):
|
||||
rec = session.get(Recording, i)
|
||||
assert rec.status == "error"
|
||||
assert rec.error_message == "Abgebrochen durch Neustart"
|
||||
assert rec.stopped_at is not None
|
||||
finally:
|
||||
session.close()
|
||||
Reference in New Issue
Block a user