feat: Eingabeprüfung, Verschlüsselung und Zeitlimits

Strikte Validierung von Server, Freigabe, Pfaden und Mount-Optionen
(Allowlist, gesperrte Zugangsdaten-Schlüssel), Fernet-Schlüssel mit 0600,
Ed25519-Schlüsselerzeugung, Schwärzung von Geheimnissen und Dateizugriffe
mit Zeitlimit in Daemon-Threads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-10 18:46:50 +02:00
co-authored by Claude Opus 5.5
parent 278180c541
commit 689ddf0d94
5 changed files with 617 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
"""Geheimnisse (Fallstrick 7): Fernet-Verschlüsselung, SSH-Schlüssel, Schwärzung."""
from __future__ import annotations
import os
import stat
from collections.abc import Iterable
from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from netmover.logging_setup import event, get_logger
log = get_logger(__name__)
REDACTED = "••••••"
class SecretError(RuntimeError):
pass
def load_or_create_key(path: Path) -> bytes:
"""Liest den Fernet-Schlüssel oder legt ihn beim ersten Start mit Modus 0600 an."""
path.parent.mkdir(parents=True, exist_ok=True)
try:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
mode = stat.S_IMODE(path.stat().st_mode)
if mode & 0o077:
path.chmod(0o600)
event(log, "secret_key.mode_fixed", path=str(path), old_mode=oct(mode))
key = path.read_bytes().strip()
try:
Fernet(key)
except ValueError as exc:
raise SecretError(f"{path} enthält keinen gültigen Schlüssel") from exc
return key
key = Fernet.generate_key()
with os.fdopen(fd, "wb") as fh:
fh.write(key + b"\n")
fh.flush()
os.fsync(fh.fileno())
event(log, "secret_key.created", path=str(path))
return key
class SecretBox:
def __init__(self, key: bytes) -> None:
self._fernet = Fernet(key)
def encrypt(self, plaintext: str) -> str:
return self._fernet.encrypt(plaintext.encode()).decode()
def decrypt(self, token: str) -> str:
try:
return self._fernet.decrypt(token.encode()).decode()
except InvalidToken as exc:
raise SecretError(
"Gespeichertes Geheimnis lässt sich nicht entschlüsseln – "
"wurde secret.key ausgetauscht?"
) from exc
def generate_ssh_keypair(comment: str = "netmover") -> tuple[str, str]:
"""Erzeugt ein Ed25519-Schlüsselpaar: (privat im OpenSSH-Format, öffentlich)."""
key = Ed25519PrivateKey.generate()
private = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.OpenSSH,
serialization.NoEncryption(),
).decode()
public = (
key.public_key()
.public_bytes(serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH)
.decode()
)
return private, f"{public} {comment}"
def redact(text: str, secrets: Iterable[str | None]) -> str:
"""Ersetzt bekannte Geheimnisse, bevor Ausgaben protokolliert oder angezeigt werden."""
# Längste zuerst, damit Teilstrings eines längeren Geheimnisses nicht stehen bleiben.
for secret in sorted({s for s in secrets if s and len(s) >= 3}, key=len, reverse=True):
text = text.replace(secret, REDACTED)
return text
+58
View File
@@ -0,0 +1,58 @@
"""Dateizugriffe mit Zeitlimit (Fallstrick 13).
Ein Zugriff auf einen hängenden ``hard``-NFS-Mount blockiert im Kernel und lässt
sich weder abbrechen noch beenden. Deshalb läuft jeder solche Zugriff in einem
eigenen Daemon-Thread: Läuft das Zeitlimit ab, geben wir auf und lassen den
Thread zurück. Er blockiert nur sich selbst, nie den Scheduler oder andere
Regeln. Bewusst kein Thread-Pool – hängende Threads würden ihn auffüllen.
"""
from __future__ import annotations
import threading
from collections.abc import Callable
from netmover.logging_setup import event, get_logger
log = get_logger(__name__)
_abandoned = 0
_abandoned_lock = threading.Lock()
class OperationTimeout(TimeoutError):
def __init__(self, what: str, seconds: float) -> None:
super().__init__(f"{what}: keine Antwort nach {seconds:g} s")
self.what = what
self.seconds = seconds
def abandoned_threads() -> int:
"""Anzahl zurückgelassener (vermutlich hängender) Threads – für die Statusseite."""
return _abandoned
def run_with_timeout[T](fn: Callable[[], T], seconds: float, what: str) -> T:
result: list[T] = []
error: list[BaseException] = []
done = threading.Event()
def target() -> None:
try:
result.append(fn())
except BaseException as exc:
error.append(exc)
finally:
done.set()
thread = threading.Thread(target=target, name=f"nm-timeout:{what}", daemon=True)
thread.start()
if not done.wait(seconds):
global _abandoned
with _abandoned_lock:
_abandoned += 1
event(log, "operation.timeout", level=30, what=what, seconds=seconds)
raise OperationTimeout(what, seconds)
if error:
raise error[0]
return result[0]
+245
View File
@@ -0,0 +1,245 @@
"""Validierung aller Eingaben, die in einen ``mount``-Aufruf oder einen Pfad fließen.
Fallstrick 5 (Shell-Injection): Server, Freigabe und Zusatzoptionen kommen aus
einem Webformular. Sie werden hier gegen strikte Muster geprüft; Optionen nur
gegen eine Allowlist erlaubter Schlüssel. Zusätzlich ruft das Backend ``mount``
ausschließlich mit Argumentliste auf – beides zusammen, nicht eins von beiden.
"""
from __future__ import annotations
import ipaddress
import re
import unicodedata
from dataclasses import dataclass
from pathlib import PurePosixPath
from netmover.models import MountType
class ValidationError(ValueError):
"""Eingabe ungültig. ``field`` benennt das Formularfeld, ``str(exc)`` den Grund."""
def __init__(self, field: str, message: str) -> None:
super().__init__(message)
self.field = field
_HOSTNAME_RE = re.compile(
r"^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$"
)
# Freigabename, optional mit Unterpfad: "scans" oder "scans/eingang"
_CIFS_SHARE_RE = re.compile(r"^[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$")
# Absoluter Exportpfad bzw. entfernter Basispfad: "/volume1/scans"
_ABS_PATH_RE = re.compile(r"^/(?:[A-Za-z0-9._-]+/?)*$")
_OPT_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*$")
_OPT_VALUE_RE = re.compile(r"^[A-Za-z0-9._:/@-]+$")
_MODE_RE = re.compile(r"^0?[0-7]{3}$")
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._@$\\-]{1,255}$")
# Schlüssel, die ausschließlich das Tool setzt.
FORBIDDEN_OPTION_KEYS = frozenset(
{"credentials", "cred", "username", "user", "password", "password2", "pass", "domain"}
)
# Schlüssel mit eigenem Formularfeld.
DEDICATED_OPTION_KEYS = frozenset({"uid", "gid", "file_mode", "dir_mode"})
_COMMON_FLAGS = frozenset({"ro", "rw", "noexec", "nosuid", "nodev", "hard", "soft", "noatime"})
ALLOWED_OPTIONS: dict[MountType, frozenset[str]] = {
MountType.CIFS: _COMMON_FLAGS
| {
"vers", "sec", "iocharset", "noperm", "nounix", "serverino", "noserverino",
"cache", "actimeo", "rsize", "wsize", "seal", "nobrl", "mfsymlinks",
"forceuid", "forcegid", "noacl", "echo_interval", "handlecache",
"nohandlecache", "nocase", "port", "max_credits", "retrans",
},
MountType.NFS4: _COMMON_FLAGS
| {
"vers", "proto", "port", "timeo", "retrans", "rsize", "wsize", "ac", "noac",
"actimeo", "lookupcache", "nconnect", "sec", "softerr", "fsc", "clientaddr",
},
MountType.NFS3: _COMMON_FLAGS
| {
"proto", "port", "timeo", "retrans", "rsize", "wsize", "ac", "noac",
"actimeo", "lookupcache", "nconnect", "sec", "nolock", "local_lock",
"mountport", "mountproto", "softerr", "fsc",
},
MountType.SFTP: frozenset(),
} # fmt: skip
_NFS4_VERSIONS = frozenset({"4", "4.0", "4.1", "4.2"})
def validate_server(value: str) -> str:
value = value.strip()
if not value:
raise ValidationError("server", "Server fehlt.")
try:
ip = ipaddress.ip_address(value)
except ValueError:
ip = None
if ip is not None:
if ip.version == 6:
raise ValidationError(
"server", "IPv6-Adressen werden nicht unterstützt – bitte Hostname verwenden."
)
return value
if not _HOSTNAME_RE.fullmatch(value):
raise ValidationError(
"server", "Ungültiger Servername – erlaubt sind Hostname oder IPv4-Adresse."
)
return value
def _no_dot_segments(field: str, value: str) -> None:
if any(part in {".", ".."} for part in value.split("/")):
raise ValidationError(field, "„.“ und „..“ sind als Pfadbestandteil nicht erlaubt.")
def validate_share(mount_type: MountType, value: str) -> str:
value = value.strip()
if mount_type is MountType.CIFS:
value = value.strip("/")
if not _CIFS_SHARE_RE.fullmatch(value):
raise ValidationError(
"share",
"Ungültige Freigabe – erlaubt sind Buchstaben, Ziffern, „.“, „_“, „-“ und "
"„/“ als Trenner (z. B. „scans“ oder „scans/eingang“).",
)
else:
if len(value) > 1:
value = value.rstrip("/")
if not _ABS_PATH_RE.fullmatch(value):
raise ValidationError(
"share",
"Ungültiger Pfad – absolut, nur Buchstaben, Ziffern, „.“, „_“, „-“ "
"(z. B. „/volume1/scans“).",
)
_no_dot_segments("share", value)
return value
def validate_mode(field: str, value: str | None) -> str | None:
if value is None or value.strip() == "":
return None
value = value.strip()
if not _MODE_RE.fullmatch(value):
raise ValidationError(field, "Modus bitte oktal angeben, z. B. „0644“.")
return value.rjust(4, "0")
def validate_id(field: str, value: int | str | None) -> int | None:
if value is None or (isinstance(value, str) and value.strip() == ""):
return None
try:
num = int(value)
except (TypeError, ValueError):
raise ValidationError(field, "Bitte eine ganze Zahl angeben.") from None
if not 0 <= num <= 2**32 - 2:
raise ValidationError(field, "Wert außerhalb des gültigen Bereichs.")
return num
def validate_port(value: int | str | None) -> int | None:
num = validate_id("port", value)
if num is not None and not 1 <= num <= 65535:
raise ValidationError("port", "Port muss zwischen 1 und 65535 liegen.")
return num
def validate_username(value: str) -> str:
value = value.strip()
if not _USERNAME_RE.fullmatch(value):
raise ValidationError("username", "Ungültiger Benutzername.")
return value
def validate_secret(value: str) -> str:
"""Passwörter dürfen alles enthalten außer Zeilenumbrüchen und NUL – sie landen
zeilenweise in der Credentials-Datei von ``mount.cifs``."""
if any(c in value for c in "\r\n\0"):
raise ValidationError("password", "Passwort darf keine Zeilenumbrüche enthalten.")
return value
@dataclass(frozen=True)
class MountOption:
key: str
value: str | None = None
def render(self) -> str:
return self.key if self.value is None else f"{self.key}={self.value}"
def parse_extra_options(mount_type: MountType, raw: str) -> list[MountOption]:
"""Zerlegt „vers=3.0,noserverino“ und prüft jede Option gegen die Allowlist."""
options: list[MountOption] = []
seen: set[str] = set()
for chunk in raw.replace("\n", ",").split(","):
chunk = chunk.strip()
if not chunk:
continue
key, sep, value = chunk.partition("=")
key = key.strip().lower()
val: str | None = value.strip() if sep else None
if not _OPT_KEY_RE.fullmatch(key):
raise ValidationError("extra_options", f"Ungültiger Optionsname: {key!r}")
if key in FORBIDDEN_OPTION_KEYS:
raise ValidationError(
"extra_options",
f"Option „{key}“ ist gesperrt – Zugangsdaten setzt NetMover selbst.",
)
if key in DEDICATED_OPTION_KEYS:
raise ValidationError("extra_options", f"Option „{key}“ bitte im eigenen Feld angeben.")
if key not in ALLOWED_OPTIONS[mount_type]:
raise ValidationError(
"extra_options",
f"Option „{key}“ ist für {mount_type.value} nicht erlaubt.",
)
if val is not None and not _OPT_VALUE_RE.fullmatch(val):
raise ValidationError(
"extra_options",
f"Ungültiger Wert für „{key}“ – erlaubt sind A–Z, 0–9 und . _ : / @ -",
)
if key == "vers" and mount_type is MountType.NFS4 and val not in _NFS4_VERSIONS:
raise ValidationError("extra_options", "Für nfs4 ist nur vers=4, 4.0, 4.1, 4.2 gültig.")
if key in seen:
raise ValidationError("extra_options", f"Option „{key}“ ist doppelt angegeben.")
seen.add(key)
options.append(MountOption(key, val))
return options
def slugify(name: str) -> str:
"""Erzeugt den Verzeichnisnamen unter dem Mount-Basisverzeichnis."""
text = name.replace("ß", "ss")
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
text = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
text = text[:48].strip("-")
if not text:
raise ValidationError(
"name", "Name muss mindestens einen Buchstaben oder eine Ziffer haben."
)
return text
def safe_relpath(field: str, value: str) -> str:
"""Relativer Unterpfad für Regeln. Leerzeichen sind erlaubt (die Pfade gehen nie
durch eine Shell), Ausbrüche per ``..`` oder absolute Pfade nicht."""
value = value.strip().strip("/")
if "\0" in value:
raise ValidationError(field, "Ungültiges Zeichen im Pfad.")
if value == "":
return ""
_no_dot_segments(field, value)
return str(PurePosixPath(value))
def safe_abspath(field: str, value: str) -> str:
value = value.strip()
if "\0" in value or not value.startswith("/"):
raise ValidationError(field, "Bitte einen absoluten Pfad angeben.")
_no_dot_segments(field, value)
return str(PurePosixPath(value))