Sicherheits-Härtung und Bugfixes
This commit is contained in:
+119
-19
@@ -1,9 +1,18 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
import yaml
|
||||
|
||||
DEFAULT_DOWNLOAD_PATH = "/app/data/recordings"
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"recording": {
|
||||
"download_path": "/app/data/recordings",
|
||||
"download_path": DEFAULT_DOWNLOAD_PATH,
|
||||
"max_retries": 5,
|
||||
"retry_delay": 5,
|
||||
},
|
||||
@@ -14,7 +23,7 @@ DEFAULT_CONFIG = {
|
||||
"auth": {
|
||||
"enabled": False,
|
||||
"username": "admin",
|
||||
"password": "stream-recorder",
|
||||
"password_hash": "",
|
||||
},
|
||||
"ntfy": {
|
||||
"url": "",
|
||||
@@ -35,35 +44,126 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
}
|
||||
|
||||
PBKDF2_ITERATIONS = 600000
|
||||
|
||||
|
||||
LEGACY_DEFAULT_PASSWORD = "stream-recorder"
|
||||
|
||||
|
||||
def hash_password(password: str, iterations: int | None = None) -> str:
|
||||
iterations = iterations or PBKDF2_ITERATIONS
|
||||
salt = secrets.token_bytes(16)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
||||
return "pbkdf2_sha256${}${}${}".format(
|
||||
iterations,
|
||||
base64.b64encode(salt).decode("ascii"),
|
||||
base64.b64encode(dk).decode("ascii"),
|
||||
)
|
||||
|
||||
|
||||
def verify_password(password: str, encoded: str) -> bool:
|
||||
try:
|
||||
algo, iterations, salt_b64, hash_b64 = encoded.split("$", 3)
|
||||
if algo != "pbkdf2_sha256":
|
||||
return False
|
||||
salt = base64.b64decode(salt_b64)
|
||||
expected = base64.b64decode(hash_b64)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations))
|
||||
return hmac.compare_digest(dk, expected)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def recording_roots() -> list[str]:
|
||||
raw = os.environ.get("RECORDING_ROOTS", os.pathsep.join(["/app/data/recordings", "/recordings"]))
|
||||
return [os.path.realpath(r) for r in raw.split(os.pathsep) if r.strip()]
|
||||
|
||||
|
||||
def validate_download_path(path: str) -> str:
|
||||
"""Gibt den normalisierten Pfad zurück oder wirft ValueError."""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError("Download-Pfad darf nicht leer sein")
|
||||
if "\x00" in path:
|
||||
raise ValueError("Download-Pfad enthält ungültige Zeichen")
|
||||
if not os.path.isabs(path):
|
||||
raise ValueError("Download-Pfad muss absolut sein")
|
||||
real = os.path.realpath(path)
|
||||
roots = recording_roots()
|
||||
for root in roots:
|
||||
if real == root or real.startswith(root.rstrip(os.sep) + os.sep):
|
||||
return real
|
||||
raise ValueError(f"Download-Pfad muss innerhalb von {', '.join(roots)} liegen")
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self, config_path: str = "/app/data/config.yml"):
|
||||
self.config_path = config_path
|
||||
self.data = {}
|
||||
self.lock = threading.RLock()
|
||||
self.generated_password = None
|
||||
self.password_migrated = False
|
||||
self.default_password_active = False
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
self.data = _deep_copy(DEFAULT_CONFIG)
|
||||
if os.path.exists(self.config_path):
|
||||
with open(self.config_path, "r") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
_deep_merge(self.data, user_config)
|
||||
else:
|
||||
self.save()
|
||||
with self.lock:
|
||||
self.data = _deep_copy(DEFAULT_CONFIG)
|
||||
if os.path.exists(self.config_path):
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
_deep_merge(self.data, user_config)
|
||||
auth = self.data.setdefault("auth", {})
|
||||
if "password" in auth:
|
||||
plain = auth.pop("password")
|
||||
if plain and not auth.get("password_hash"):
|
||||
auth["password_hash"] = hash_password(str(plain))
|
||||
self.password_migrated = True
|
||||
self.save()
|
||||
# Altes Standardpasswort gilt als nicht gesetzt, sonst würde
|
||||
# "Auth aktivieren" ohne neues Passwort ein bekanntes Passwort scharf schalten.
|
||||
pw_hash = auth.get("password_hash")
|
||||
if pw_hash and verify_password(LEGACY_DEFAULT_PASSWORD, pw_hash):
|
||||
if auth.get("enabled"):
|
||||
self.default_password_active = True
|
||||
else:
|
||||
auth["password_hash"] = ""
|
||||
self.save()
|
||||
else:
|
||||
password = secrets.token_urlsafe(12)
|
||||
self.data["auth"]["enabled"] = True
|
||||
self.data["auth"]["username"] = "admin"
|
||||
self.data["auth"]["password_hash"] = hash_password(password)
|
||||
self.generated_password = password
|
||||
self.save()
|
||||
|
||||
def save(self):
|
||||
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
||||
with open(self.config_path, "w") as f:
|
||||
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
with self.lock:
|
||||
directory = os.path.dirname(self.config_path) or "."
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=".config.", suffix=".tmp", dir=directory)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
yaml.dump(self.data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.chmod(tmp_path, 0o600)
|
||||
os.replace(tmp_path, self.config_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def get(self, *keys, default=None):
|
||||
val = self.data
|
||||
for key in keys:
|
||||
if isinstance(val, dict) and key in val:
|
||||
val = val[key]
|
||||
else:
|
||||
return default
|
||||
return val
|
||||
with self.lock:
|
||||
val = self.data
|
||||
for key in keys:
|
||||
if isinstance(val, dict) and key in val:
|
||||
val = val[key]
|
||||
else:
|
||||
return default
|
||||
return val
|
||||
|
||||
|
||||
def _deep_copy(d):
|
||||
|
||||
Reference in New Issue
Block a user