Files
netmover/tests/test_validation.py
T
Florian WildeandClaude Opus 5.5 689ddf0d94 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>
2026-10-10 18:46:50 +02:00

160 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Fallstrick 5 (Shell-Injection) und 6 (Mountpoints) – Eingabeprüfung."""
from __future__ import annotations
import pytest
from netmover.models import MountType
from netmover.validation import (
ValidationError,
parse_extra_options,
safe_abspath,
safe_relpath,
slugify,
validate_mode,
validate_secret,
validate_server,
validate_share,
)
INJECTION = [
"nas;rm -rf /",
"nas && reboot",
"nas|id",
"$(id)",
"`id`",
"nas\nid",
"nas,credentials=/etc/shadow",
"nas name",
"-oProxyCommand=x",
"",
"nas/../etc",
]
@pytest.mark.parametrize("value", INJECTION)
def test_server_rejects_injection(value: str) -> None:
with pytest.raises(ValidationError) as exc:
validate_server(value)
assert exc.value.field == "server"
@pytest.mark.parametrize("value", ["192.168.1.10", "nas", "nas.fritz.box", "ds-920"])
def test_server_accepts_hosts(value: str) -> None:
assert validate_server(value) == value
def test_server_rejects_ipv6_with_clear_message() -> None:
with pytest.raises(ValidationError, match="IPv6"):
validate_server("fe80::1")
@pytest.mark.parametrize(
"value",
["scans,uid=0", "scans $HOME", "scans`id`", "scans\nfoo", "../etc", "sc ans", "a/../b", "C$"],
)
def test_cifs_share_rejects(value: str) -> None:
with pytest.raises(ValidationError):
validate_share(MountType.CIFS, value)
def test_cifs_share_normalises_slashes() -> None:
assert validate_share(MountType.CIFS, "/scans/eingang/") == "scans/eingang"
@pytest.mark.parametrize("value", ["volume1/scans", "/volume1/scans,rw", "/a b", "/a/../b", "/$x"])
def test_nfs_export_rejects(value: str) -> None:
with pytest.raises(ValidationError):
validate_share(MountType.NFS4, value)
def test_nfs_export_accepts() -> None:
assert validate_share(MountType.NFS3, "/volume1/scans/") == "/volume1/scans"
@pytest.mark.parametrize("key", ["credentials", "username", "password", "pass", "user", "domain"])
def test_forbidden_option_keys(key: str) -> None:
with pytest.raises(ValidationError, match="gesperrt"):
parse_extra_options(MountType.CIFS, f"{key}=x")
@pytest.mark.parametrize("key", ["uid", "gid", "file_mode", "dir_mode"])
def test_dedicated_option_keys(key: str) -> None:
with pytest.raises(ValidationError, match="eigenen Feld"):
parse_extra_options(MountType.CIFS, f"{key}=1000")
@pytest.mark.parametrize(
"raw",
[
"vers=3.0;id",
"vers=$(id)",
"vers=`id`",
"vers=3 0",
"iocharset=utf8,x-systemd.automount",
"exec", # nicht in der Allowlist
"VERS=3.0=1",
],
)
def test_option_values_and_unknown_keys(raw: str) -> None:
with pytest.raises(ValidationError):
parse_extra_options(MountType.CIFS, raw)
def test_options_allowlist_per_type() -> None:
with pytest.raises(ValidationError, match="nicht erlaubt"):
parse_extra_options(MountType.NFS4, "nolock")
assert [o.render() for o in parse_extra_options(MountType.NFS3, "nolock, timeo=600")] == [
"nolock",
"timeo=600",
]
def test_options_parse_ok_and_duplicates() -> None:
opts = parse_extra_options(MountType.CIFS, "vers=3.0, noserverino,\niocharset=utf8")
assert [o.render() for o in opts] == ["vers=3.0", "noserverino", "iocharset=utf8"]
with pytest.raises(ValidationError, match="doppelt"):
parse_extra_options(MountType.CIFS, "vers=3.0,vers=2.1")
def test_nfs4_vers_restricted() -> None:
assert parse_extra_options(MountType.NFS4, "vers=4.1")[0].value == "4.1"
with pytest.raises(ValidationError):
parse_extra_options(MountType.NFS4, "vers=3")
def test_mode() -> None:
assert validate_mode("file_mode", "644") == "0644"
assert validate_mode("file_mode", "") is None
with pytest.raises(ValidationError):
validate_mode("file_mode", "0999")
def test_secret_rejects_newlines() -> None:
assert validate_secret("p@ss,w=rd;$`") == "p@ss,w=rd;$`"
with pytest.raises(ValidationError):
validate_secret("pass\nusername=root")
def test_slugify() -> None:
assert slugify("Synology Scans (Büro)") == "synology-scans-buro"
assert slugify("Straße/../..") == "strasse"
with pytest.raises(ValidationError):
slugify("../..")
def test_relpath() -> None:
assert safe_relpath("p", "/Scans Max/eingang/") == "Scans Max/eingang"
assert safe_relpath("p", "") == ""
for bad in ["../x", "a/../../b", "a/./b", "a\0b"]:
with pytest.raises(ValidationError):
safe_relpath("p", bad)
def test_abspath() -> None:
assert safe_abspath("p", "/opt/consume/") == "/opt/consume"
with pytest.raises(ValidationError):
safe_abspath("p", "opt/consume")
with pytest.raises(ValidationError):
safe_abspath("p", "/opt/../etc")