177 lines
7.3 KiB
Python
177 lines
7.3 KiB
Python
|
|
import importlib
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
import tempfile
|
||
|
|
import types
|
||
|
|
import unittest
|
||
|
|
from unittest import mock
|
||
|
|
import shutil
|
||
|
|
import sqlite3
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
|
||
|
|
ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
sys.path.insert(0, str(ROOT))
|
||
|
|
|
||
|
|
|
||
|
|
class RuntimeSecretsTests(unittest.TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
self.temp = tempfile.TemporaryDirectory()
|
||
|
|
from app.utils import Secrets
|
||
|
|
self.module = Secrets
|
||
|
|
self.old_path = Secrets.RUNTIME_SECRETS_FILE
|
||
|
|
self.old_cache = Secrets._CACHE
|
||
|
|
Secrets.RUNTIME_SECRETS_FILE = Path(self.temp.name) / "runtime.json"
|
||
|
|
Secrets._CACHE = None
|
||
|
|
|
||
|
|
def tearDown(self):
|
||
|
|
self.module.RUNTIME_SECRETS_FILE = self.old_path
|
||
|
|
self.module._CACHE = self.old_cache
|
||
|
|
self.temp.cleanup()
|
||
|
|
|
||
|
|
def test_generates_unique_nonempty_secrets_without_public_config(self):
|
||
|
|
internal = self.module.get_runtime_secret("internal_api_secret")
|
||
|
|
media = self.module.get_runtime_secret("media_secret")
|
||
|
|
self.assertGreaterEqual(len(internal), 40)
|
||
|
|
self.assertGreaterEqual(len(media), 32)
|
||
|
|
self.assertNotEqual(internal, media)
|
||
|
|
data = json.loads(self.module.RUNTIME_SECRETS_FILE.read_text(encoding="utf-8"))
|
||
|
|
self.assertEqual(data["internal_api_secret"], internal)
|
||
|
|
|
||
|
|
def test_environment_value_overrides_file(self):
|
||
|
|
key = "MONITOR_INTERNAL_API_SECRET"
|
||
|
|
old = os.environ.get(key)
|
||
|
|
os.environ[key] = "environment-secret-value"
|
||
|
|
try:
|
||
|
|
self.assertEqual(
|
||
|
|
self.module.get_runtime_secret("internal_api_secret"),
|
||
|
|
"environment-secret-value",
|
||
|
|
)
|
||
|
|
finally:
|
||
|
|
if old is None:
|
||
|
|
os.environ.pop(key, None)
|
||
|
|
else:
|
||
|
|
os.environ[key] = old
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeRequest:
|
||
|
|
def __init__(self, remote="127.0.0.1", method="POST", path="/inner/test", body=b"{}", headers=None, token=""):
|
||
|
|
self.META = {"REMOTE_ADDR": remote}
|
||
|
|
self.method = method
|
||
|
|
self.path_info = path
|
||
|
|
self.body = body
|
||
|
|
self.headers = headers or {}
|
||
|
|
self.GET = {"token": token} if token else {}
|
||
|
|
|
||
|
|
|
||
|
|
class InternalRequestAuthTests(unittest.TestCase):
|
||
|
|
@classmethod
|
||
|
|
def setUpClass(cls):
|
||
|
|
try:
|
||
|
|
import django.conf # noqa: F401
|
||
|
|
except ImportError:
|
||
|
|
django = types.ModuleType("django")
|
||
|
|
conf = types.ModuleType("django.conf")
|
||
|
|
conf.settings = types.SimpleNamespace(MONITOR_INTERNAL_API_SECRET="test-internal-secret")
|
||
|
|
django.conf = conf
|
||
|
|
sys.modules["django"] = django
|
||
|
|
sys.modules["django.conf"] = conf
|
||
|
|
cls.security = importlib.import_module("app.security")
|
||
|
|
cls.security.settings.MONITOR_INTERNAL_API_SECRET = "test-internal-secret"
|
||
|
|
|
||
|
|
def test_valid_hmac_from_loopback_is_accepted(self):
|
||
|
|
now = 1_800_000_000
|
||
|
|
body = b'{"x":1}'
|
||
|
|
headers = self.security.build_internal_auth_headers("POST", "/inner/test", body, timestamp=now)
|
||
|
|
request = _FakeRequest(body=body, headers=headers)
|
||
|
|
self.assertTrue(self.security.verify_internal_request(request, now=now))
|
||
|
|
|
||
|
|
def test_non_loopback_is_rejected_even_with_valid_hmac(self):
|
||
|
|
now = 1_800_000_000
|
||
|
|
headers = self.security.build_internal_auth_headers("POST", "/inner/test", b"{}", timestamp=now)
|
||
|
|
request = _FakeRequest(remote="192.168.1.10", headers=headers)
|
||
|
|
self.assertFalse(self.security.verify_internal_request(request, now=now))
|
||
|
|
|
||
|
|
def test_tampered_body_and_stale_signature_are_rejected(self):
|
||
|
|
now = 1_800_000_000
|
||
|
|
headers = self.security.build_internal_auth_headers("POST", "/inner/test", b"{}", timestamp=now)
|
||
|
|
self.assertFalse(self.security.verify_internal_request(
|
||
|
|
_FakeRequest(body=b'{"tampered":1}', headers=headers), now=now
|
||
|
|
))
|
||
|
|
self.assertFalse(self.security.verify_internal_request(
|
||
|
|
_FakeRequest(headers=headers), now=now + 61
|
||
|
|
))
|
||
|
|
|
||
|
|
def test_zlm_token_requires_loopback(self):
|
||
|
|
self.assertTrue(self.security.verify_internal_request(
|
||
|
|
_FakeRequest(token="test-internal-secret")
|
||
|
|
))
|
||
|
|
self.assertFalse(self.security.verify_internal_request(
|
||
|
|
_FakeRequest(remote="10.0.0.2", token="test-internal-secret")
|
||
|
|
))
|
||
|
|
|
||
|
|
|
||
|
|
class SourceBoundaryTests(unittest.TestCase):
|
||
|
|
def test_public_config_contains_no_runtime_credentials(self):
|
||
|
|
config = json.loads((ROOT / "config.json").read_text(encoding="utf-8"))
|
||
|
|
self.assertNotIn("safe", config)
|
||
|
|
self.assertNotIn("mediaSecret", config)
|
||
|
|
self.assertNotIn("sipServerPass", config.get("sipServer", {}))
|
||
|
|
self.assertNotIn("sipServerNonce", config.get("sipServer", {}))
|
||
|
|
|
||
|
|
def test_snap_uses_no_shell_and_middleware_has_no_safe_bypass(self):
|
||
|
|
nvr = (ROOT / "app" / "views" / "NvrView.py").read_text(encoding="utf-8")
|
||
|
|
middleware = (ROOT / "app" / "middleware.py").read_text(encoding="utf-8")
|
||
|
|
self.assertIn("shell=False", nvr)
|
||
|
|
self.assertNotIn("shell=True", nvr)
|
||
|
|
self.assertNotIn("HTTP_SAFE", middleware)
|
||
|
|
self.assertNotIn("'/nvr/openSnap'", middleware)
|
||
|
|
|
||
|
|
def test_download_endpoint_no_longer_accepts_filename_parameter(self):
|
||
|
|
storage = (ROOT / "app" / "views" / "StorageView.py").read_text(encoding="utf-8")
|
||
|
|
self.assertIn('params.get("file_id"', storage)
|
||
|
|
self.assertNotIn('params.get("filename"', storage)
|
||
|
|
|
||
|
|
|
||
|
|
class SecureInitializeTests(unittest.TestCase):
|
||
|
|
def test_replaces_users_clears_sessions_and_preserves_business_data(self):
|
||
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||
|
|
database = Path(temp_dir) / "test.sqlite3"
|
||
|
|
shutil.copy2(ROOT / "monitor.sqlite3", database)
|
||
|
|
before_con = sqlite3.connect(database)
|
||
|
|
try:
|
||
|
|
before_streams = before_con.execute("select count(*) from av_stream").fetchone()[0]
|
||
|
|
finally:
|
||
|
|
before_con.close()
|
||
|
|
|
||
|
|
spec = importlib.util.spec_from_file_location(
|
||
|
|
"secure_initialize_test", ROOT / "scripts" / "secure_initialize.py"
|
||
|
|
)
|
||
|
|
module = importlib.util.module_from_spec(spec)
|
||
|
|
spec.loader.exec_module(module)
|
||
|
|
argv = [
|
||
|
|
"secure_initialize.py", "--database", str(database),
|
||
|
|
"--admin-username", "secureadmin", "--admin-email", "admin@example.invalid",
|
||
|
|
]
|
||
|
|
with mock.patch.object(sys, "argv", argv), \
|
||
|
|
mock.patch.object(module.getpass, "getpass", side_effect=["Long-Test-Password-42!", "Long-Test-Password-42!"]), \
|
||
|
|
mock.patch("app.utils.Secrets.rotate_runtime_secrets", return_value=""):
|
||
|
|
module.main()
|
||
|
|
|
||
|
|
con = sqlite3.connect(database)
|
||
|
|
try:
|
||
|
|
users = con.execute("select username,is_superuser,is_staff,is_active from auth_user").fetchall()
|
||
|
|
sessions = con.execute("select count(*) from django_session").fetchone()[0]
|
||
|
|
after_streams = con.execute("select count(*) from av_stream").fetchone()[0]
|
||
|
|
finally:
|
||
|
|
con.close()
|
||
|
|
self.assertEqual(users, [("secureadmin", 1, 1, 1)])
|
||
|
|
self.assertEqual(sessions, 0)
|
||
|
|
self.assertEqual(after_streams, before_streams)
|
||
|
|
self.assertTrue(list(Path(temp_dir).glob("test.sqlite3.backup-*")))
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|