Sicherheits-Härtung und Bugfixes

This commit is contained in:
Patrick Asmus
2026-10-11 17:39:03 +02:00
parent c3f68ace96
commit 65b0b5ac0f
25 changed files with 2577 additions and 686 deletions
+54
View File
@@ -0,0 +1,54 @@
import base64
import os
import sys
import tempfile
import pytest
_TMP = tempfile.mkdtemp(prefix="stream-recorder-tests-")
os.environ["DATA_DIR"] = os.path.join(_TMP, "data")
os.environ["STATIC_DIR"] = os.path.join(_TMP, "no-static")
os.environ["RECORDING_ROOTS"] = os.path.join(_TMP, "recordings")
os.environ.pop("ENABLE_API_DOCS", None)
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import app as app_module # noqa: E402
from config import hash_password # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from models import get_session, Job, Station, Recording, LogEntry # noqa: E402
TEST_PASSWORD = "geheim-12345"
# Schneller Hash für Tests
TEST_HASH = hash_password(TEST_PASSWORD, iterations=1000)
def basic(user, password):
return "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
@pytest.fixture
def app_mod():
return app_module
@pytest.fixture
def client(app_mod):
cfg = app_mod.config
with cfg.lock:
cfg.data["auth"] = {"enabled": False, "username": "admin", "password_hash": TEST_HASH}
cfg.data["ntfy"] = {"url": "https://ntfy.example/topic", "token": "tk_secret", "events": "error"}
cfg.data["plik"] = {"url": "https://plik.example", "api_key": "plik_secret", "ttl": "30d"}
cfg.data["recording"]["download_path"] = os.environ["RECORDING_ROOTS"]
app_mod.clear_auth_cache()
app_mod.reset_auth_failures()
session = get_session()
try:
for model in (Job, Station, Recording, LogEntry):
session.query(model).delete()
session.commit()
finally:
session.close()
return TestClient(app_mod.app)
+282
View File
@@ -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()
@@ -0,0 +1,92 @@
import datetime
import os
from unittest import mock
import yaml
import config as config_module
from config import Config, verify_password
from models import get_session, Job
from scheduler import StreamScheduler
def _add_job(**kw):
session = get_session()
try:
job = Job(name=kw.pop("name", "j"), stream_url="https://e.example/s", schedule_enabled=True, **kw)
session.add(job)
session.commit()
return job.id
finally:
session.close()
def test_bad_legacy_job_does_not_break_tick(client):
_add_job(name="kaputt1", schedule_start="00:00", schedule_stop="xx")
_add_job(name="kaputt2", schedule_start="25:99", schedule_days="foo-bar")
_add_job(name="kaputt3", schedule_start="00:00", schedule_once=True, schedule_date="kein-datum")
good = _add_job(name="gut", schedule_start="00:00", schedule_stop="", schedule_days="*")
rec = mock.MagicMock()
sched = StreamScheduler(rec)
with mock.patch("scheduler.logger") as log:
sched._tick()
sched._tick()
started = [c.kwargs["job_id"] for c in rec.start_recording.call_args_list]
assert started == [good]
# Fehler nur einmal pro Job geloggt, nicht bei jedem Tick
assert log.error.call_count == 1
def test_started_keys_pruned(client):
sched = StreamScheduler(mock.MagicMock())
old = (datetime.date.today() - datetime.timedelta(days=5)).isoformat()
today = datetime.date.today().isoformat()
sched._started_keys = {f"1|False|{old}|10:00", f"2|False|{today}|10:00", "kaputt"}
sched._prune_started_keys(datetime.datetime.now())
assert sched._started_keys == {f"2|False|{today}|10:00"}
def test_first_start_generates_password(tmp_path, monkeypatch):
monkeypatch.setattr(config_module, "PBKDF2_ITERATIONS", 1000)
path = tmp_path / "config.yml"
cfg = Config(str(path))
assert cfg.generated_password
data = yaml.safe_load(path.read_text(encoding="utf-8"))
assert data["auth"]["enabled"] is True
assert "password" not in data["auth"]
assert verify_password(cfg.generated_password, data["auth"]["password_hash"])
assert cfg.generated_password not in path.read_text(encoding="utf-8")
if os.name == "posix":
assert (os.stat(path).st_mode & 0o777) == 0o600
# Zweiter Start: kein neues Passwort
assert Config(str(path)).generated_password is None
def test_plaintext_password_migration(tmp_path):
path = tmp_path / "config.yml"
path.write_text("auth:\n enabled: false\n username: admin\n password: altes-passwort\n", encoding="utf-8")
cfg = Config(str(path))
assert cfg.generated_password is None
data = yaml.safe_load(path.read_text(encoding="utf-8"))
assert "password" not in data["auth"]
assert data["auth"]["enabled"] is False
assert verify_password("altes-passwort", data["auth"]["password_hash"])
def test_legacy_default_password_counts_as_unset(tmp_path):
path = tmp_path / "config.yml"
path.write_text("auth:\n enabled: false\n username: admin\n password: stream-recorder\n", encoding="utf-8")
Config(str(path))
data = yaml.safe_load(path.read_text(encoding="utf-8"))
assert data["auth"]["password_hash"] == ""
def test_legacy_default_password_kept_when_auth_enabled(tmp_path):
path = tmp_path / "config.yml"
path.write_text("auth:\n enabled: true\n username: admin\n password: stream-recorder\n", encoding="utf-8")
cfg = Config(str(path))
assert cfg.default_password_active is True
assert verify_password("stream-recorder", cfg.get("auth", "password_hash"))
+118
View File
@@ -0,0 +1,118 @@
import pytest
from recorder import RecordingManager, validate_ffmpeg_args, validate_stream_url, PROTOCOL_WHITELIST
from scheduler import compute_duration, validate_schedule
@pytest.mark.parametrize("args", [
"",
"-c:a libmp3lame -b:a 128k",
"-metadata title=Foo/Bar",
"-metadata 'title=Mit Leerzeichen'",
"-vn -ar 44100 -ac 2",
"-af volume=2",
])
def test_ffmpeg_args_allowed(args):
validate_ffmpeg_args(args)
@pytest.mark.parametrize("args", [
"/app/app.py",
"-i x",
"-af amovie=/etc/passwd",
"-filter:a AMOVIE=x",
"-af sendcmd=f=x",
"-c:a",
"-c:a -b:a",
"-b:a 128k out.mp3",
"-y",
"-f segment -segment_list /tmp/x",
"-metadata ''",
"'unclosed",
"-vn " * 200,
])
def test_ffmpeg_args_rejected(args):
with pytest.raises(ValueError):
validate_ffmpeg_args(args)
def test_ffmpeg_args_error_names_token():
with pytest.raises(ValueError, match="/app/app.py"):
validate_ffmpeg_args("-vn /app/app.py")
@pytest.mark.parametrize("url", [
"https://example.com/stream.mp3",
"http://radio.example:8000/live",
"rtmp://live.example/app/key",
"rtsps://cam.example/stream",
])
def test_url_allowed(url):
assert validate_stream_url(url) == url
@pytest.mark.parametrize("url", [
"file:///etc/passwd",
"concat:/etc/passwd|/etc/shadow",
"subfile:,,start,0,end,0,,:/etc/passwd",
"https://",
"http://exa mple.com/",
"https://example.com/\nfoo",
"ftp://example.com/x",
"",
"https://example.com/" + "a" * 1000,
])
def test_url_rejected(url):
with pytest.raises(ValueError):
validate_stream_url(url)
def test_ffmpeg_cmd_has_protocol_whitelist_before_input():
mgr = RecordingManager(config=None)
cmd = mgr._build_ffmpeg_cmd("https://example.com/a.m3u8", "hls", "/x/out.mp4",
"-metadata title=X", False, None)
assert cmd.index("-protocol_whitelist") < cmd.index("-i")
assert cmd[cmd.index("-protocol_whitelist") + 1] == PROTOCOL_WHITELIST
assert "file" not in PROTOCOL_WHITELIST.split(",")
assert cmd[-1] == "/x/out.mp4"
assert cmd[-3:-1] == ["-metadata", "title=X"]
def test_ffmpeg_cmd_revalidates():
mgr = RecordingManager(config=None)
with pytest.raises(ValueError):
mgr._build_ffmpeg_cmd("https://example.com/a", "http", "/x/out.mp4", "/app/app.py", False, None)
with pytest.raises(ValueError):
mgr._build_ffmpeg_cmd("file:///etc/passwd", "http", "/x/out.mp4", "", False, None)
@pytest.mark.parametrize("job,msg", [
({"schedule_start": "25:00"}, "HH:MM"),
({"schedule_start": "10:00", "schedule_stop": "9:5"}, "HH:MM"),
({"schedule_date": "2026-02-30"}, "JJJJ"),
({"schedule_days": "mo-xx"}, "Wochentage"),
({"schedule_enabled": True}, "Startzeit"),
({"schedule_once": True, "schedule_start": "10:00"}, "Datum"),
({"schedule_date": "2026-05-02", "schedule_end_date": "2026-05-01"}, "Enddatum"),
])
def test_schedule_invalid(job, msg):
with pytest.raises(ValueError, match=msg):
validate_schedule(job)
def test_schedule_valid():
validate_schedule({"schedule_enabled": True, "schedule_start": "23:30", "schedule_stop": "01:00",
"schedule_days": "Mo-Fr,SA"})
validate_schedule({"schedule_once": True, "schedule_date": "2026-05-01",
"schedule_end_date": "2026-05-03", "schedule_start": "10:00"})
def test_compute_duration():
assert compute_duration({"max_duration": 120}, apply_buffers=True) == 120
assert compute_duration({"schedule_start": "10:00", "schedule_stop": "11:00"}, False) == 3600
assert compute_duration({"schedule_start": "23:00", "schedule_stop": "01:00"}, False) == 7200
assert compute_duration({"schedule_start": "10:00", "schedule_stop": "11:00",
"pre_buffer": 5, "post_buffer": 5}, True) == 4200
assert compute_duration({"schedule_start": "10:00", "schedule_stop": ""}, True) is None
with pytest.raises(ValueError):
compute_duration({"schedule_start": "10:00", "schedule_stop": "xx"}, False)