"""Security regression tests for Sovran Hub server helpers. Tests cover the concrete payload classes described in the security review: - DDNS URL validation (injection payloads) - Nix string escaping (injection into generated Nix source) - Nostr npub validation (Nix injection via nostr_npub) - SSH public-key validation (support-key handling) - /api/reboot auth-exemption removal These tests use only the Python standard library so they run without installing the full application dependency tree. The helper functions are replicated from server.py to allow isolated unit testing. """ import base64 import ipaddress import re import sys import types import unittest import urllib.parse # --------------------------------------------------------------------------- # Replicate the helpers under test so we can test them without importing the # full FastAPI application (which is not available in CI). # --------------------------------------------------------------------------- # ── _nix_escape ────────────────────────────────────────────────────────────── def _nix_escape(value: str) -> str: value = value.replace("\\", "\\\\") value = value.replace('"', '\\"') value = value.replace("\n", "\\n") value = value.replace("\r", "\\r") value = value.replace("\t", "\\t") value = value.replace("${", "\\${") return value # ── NPUB_RE ─────────────────────────────────────────────────────────────────── NPUB_RE = re.compile(r"^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$") # ── _validate_ddns_url ──────────────────────────────────────────────────────── _DDNS_URL_MAX_LEN = 2048 _DDNS_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]") def _validate_ddns_url(url: str) -> str: if not url: raise ValueError("DDNS URL must not be empty") if len(url) > _DDNS_URL_MAX_LEN: raise ValueError("DDNS URL exceeds maximum length") if _DDNS_CONTROL_RE.search(url): raise ValueError("DDNS URL contains control characters") try: parsed = urllib.parse.urlparse(url) except Exception: raise ValueError("DDNS URL could not be parsed") if parsed.scheme.lower() != "https": raise ValueError("DDNS URL must use the https scheme") if parsed.username or parsed.password: raise ValueError("DDNS URL must not contain credentials") if parsed.fragment: raise ValueError("DDNS URL must not contain a fragment") hostname = parsed.hostname or "" if not hostname: raise ValueError("DDNS URL must contain a hostname") try: ipaddress.ip_address(hostname) raise ValueError("DDNS URL hostname must not be a raw IP address") except ValueError as exc: if "raw IP" in str(exc): raise return url # ── _validate_ssh_pubkey ────────────────────────────────────────────────────── _SSH_PUBKEY_ALGORITHMS = frozenset([ "ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "sk-ssh-ed25519@openssh.com", ]) def _validate_ssh_pubkey(key: str) -> str: key = key.strip() if not key: raise ValueError("SSH public key must not be empty") if _DDNS_CONTROL_RE.search(key): raise ValueError("SSH public key contains control characters") if "\n" in key or "\r" in key: raise ValueError("SSH public key must be a single line") parts = key.split() if len(parts) < 2: raise ValueError("SSH public key is malformed") algo, b64 = parts[0], parts[1] if algo not in _SSH_PUBKEY_ALGORITHMS: raise ValueError(f"Unsupported SSH key algorithm: {algo!r}") try: decoded = base64.b64decode(b64, validate=True) except Exception: raise ValueError("SSH public key payload is not valid base64") if len(decoded) < 20: raise ValueError("SSH public key payload is too short") return key # --------------------------------------------------------------------------- # Test cases # --------------------------------------------------------------------------- class TestNixEscape(unittest.TestCase): """_nix_escape must prevent injection into Nix string literals.""" def test_double_quotes_escaped(self): self.assertEqual(_nix_escape('"hello"'), '\\"hello\\"') def test_backslash_escaped(self): self.assertEqual(_nix_escape("a\\b"), "a\\\\b") def test_nix_interpolation_escaped(self): result = _nix_escape("${pkgs.bash}") # Nix interpolation is prevented by the leading backslash; the raw # result must contain "\\${" (backslash then ${), not bare "${". self.assertIn("\\${", result) # The result must not start with "${" (unescaped) self.assertFalse(result.startswith("${")) def test_newline_escaped(self): result = _nix_escape("foo\nbar") self.assertNotIn("\n", result) self.assertIn("\\n", result) def test_carriage_return_escaped(self): result = _nix_escape("foo\rbar") self.assertNotIn("\r", result) def test_tab_escaped(self): result = _nix_escape("foo\tbar") self.assertNotIn("\t", result) def test_semicolons_unchanged(self): # Semicolons are safe inside Nix string literals self.assertEqual(_nix_escape("a;b"), "a;b") def test_valid_timezone(self): # Typical timezone value must pass through unchanged self.assertEqual(_nix_escape("Europe/London"), "Europe/London") def test_injection_payload_quotes_and_interpolation(self): payload = '"; import { ${builtins.readFile "/etc/shadow"} }' result = _nix_escape(payload) # Unescaped double-quotes must not appear in the result self.assertNotIn('"', result.replace('\\"', "")) # All ${...} sequences must be preceded by backslash self.assertIn("\\${", result) # No bare ${ that is not preceded by backslash import re as _re self.assertIsNone(_re.search(r'(?