security: harden data auth and deployment
This commit is contained in:
@@ -6,23 +6,25 @@ from unittest.mock import AsyncMock, call, patch
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from passlib.context import CryptContext
|
||||
from starlette.requests import Request
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.clients.base import _operation_error_message, _operation_result_message
|
||||
from backend.app.clients.jellyfin import _availability_message
|
||||
from backend.app.clients.qbittorrent import _torrent_result_message
|
||||
from backend.app.auth import require_admin
|
||||
from backend.app.auth import _load_current_user_from_token, require_admin
|
||||
from backend.app.config import settings
|
||||
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
|
||||
from backend.app.models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
|
||||
from backend.app.routers import auth as auth_router
|
||||
from backend.app.routers import admin as admin_router
|
||||
from backend.app.routers import branding as branding_router
|
||||
from backend.app.routers import portal as portal_router
|
||||
from backend.app.routers import requests as requests_router
|
||||
from backend.app.routers import site as site_router
|
||||
from backend.app.routers import status as status_router
|
||||
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
|
||||
from backend.app.security import PASSWORD_POLICY_MESSAGE, create_access_token, validate_password_policy
|
||||
from backend.app.services import password_reset
|
||||
from backend.app.services import issue_resolution
|
||||
from backend.app.services.operation_progress import (
|
||||
@@ -71,21 +73,16 @@ class TempDatabaseMixin:
|
||||
self._tempdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self._original_sqlite_path = settings.sqlite_path
|
||||
self._original_journal_mode = getattr(settings, "sqlite_journal_mode", "DELETE")
|
||||
self._original_settings_encryption_key = settings.settings_encryption_key
|
||||
settings.sqlite_path = os.path.join(self._tempdir.name, "test.db")
|
||||
settings.sqlite_journal_mode = "DELETE"
|
||||
auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
|
||||
auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
|
||||
auth_router._RESET_ATTEMPTS_BY_IP.clear()
|
||||
auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
|
||||
settings.settings_encryption_key = "bWFnZW50LXNlY3VyaXR5LXRlc3Qta2V5LTMyLWJ5dGU="
|
||||
db.init_db()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
settings.sqlite_path = self._original_sqlite_path
|
||||
settings.sqlite_journal_mode = self._original_journal_mode
|
||||
auth_router._LOGIN_ATTEMPTS_BY_IP.clear()
|
||||
auth_router._LOGIN_ATTEMPTS_BY_USER.clear()
|
||||
auth_router._RESET_ATTEMPTS_BY_IP.clear()
|
||||
auth_router._RESET_ATTEMPTS_BY_IDENTIFIER.clear()
|
||||
settings.settings_encryption_key = self._original_settings_encryption_key
|
||||
self._tempdir.cleanup()
|
||||
super_method = getattr(super(), "tearDown", None)
|
||||
if callable(super_method):
|
||||
@@ -98,7 +95,204 @@ class PasswordPolicyTests(unittest.TestCase):
|
||||
validate_password_policy("short")
|
||||
|
||||
def test_validate_password_policy_trims_whitespace(self) -> None:
|
||||
self.assertEqual(validate_password_policy(" password123 "), "password123")
|
||||
self.assertEqual(validate_password_policy(" password1234 "), "password1234")
|
||||
|
||||
|
||||
class SecurityHardeningTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self._jwt_secret = patch.object(
|
||||
settings, "jwt_secret", "security-hardening-tests-secret-123456789"
|
||||
)
|
||||
self._jwt_secret.start()
|
||||
self.addCleanup(self._jwt_secret.stop)
|
||||
|
||||
def test_sensitive_settings_are_encrypted_at_rest(self) -> None:
|
||||
db.set_setting("jellyfin_api_key", "private-api-key")
|
||||
|
||||
with db._connect() as conn:
|
||||
stored = conn.execute(
|
||||
"SELECT value FROM settings WHERE key = ?", ("jellyfin_api_key",)
|
||||
).fetchone()[0]
|
||||
|
||||
self.assertTrue(stored.startswith("enc:v1:"))
|
||||
self.assertNotIn("private-api-key", stored)
|
||||
self.assertEqual(db.get_setting("jellyfin_api_key"), "private-api-key")
|
||||
|
||||
def test_invites_are_hashed_and_rotation_invalidates_old_link(self) -> None:
|
||||
created = db.create_signup_invite(code="TopSecretInvite42")
|
||||
invite_id = int(created["id"])
|
||||
|
||||
with db._connect() as conn:
|
||||
stored = conn.execute(
|
||||
"SELECT code FROM signup_invites WHERE id = ?", (invite_id,)
|
||||
).fetchone()[0]
|
||||
|
||||
self.assertTrue(stored.startswith("sha256:"))
|
||||
self.assertNotIn("TOPSECRETINVITE42", stored.upper())
|
||||
self.assertFalse(db.get_signup_invite_by_id(invite_id)["code_available"])
|
||||
self.assertIsNotNone(db.get_signup_invite_by_code("TopSecretInvite42"))
|
||||
|
||||
rotated = db.rotate_signup_invite_code(invite_id, "ReplacementInvite99")
|
||||
self.assertTrue(rotated["code_available"])
|
||||
self.assertIsNone(db.get_signup_invite_by_code("TopSecretInvite42"))
|
||||
self.assertIsNotNone(db.get_signup_invite_by_code("ReplacementInvite99"))
|
||||
|
||||
def test_legacy_invites_and_plaintext_settings_migrate_in_place(self) -> None:
|
||||
created = db.create_signup_invite(code="TemporaryInvite77")
|
||||
with db._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE signup_invites SET code = ?, code_hint = NULL WHERE id = ?",
|
||||
("Legacy-Code-77", int(created["id"])),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
|
||||
("radarr_api_key", "legacy-plaintext-key", "2026-09-17T00:00:00+00:00"),
|
||||
)
|
||||
|
||||
db.init_db()
|
||||
|
||||
migrated = db.get_signup_invite_by_code("Legacy-Code-77")
|
||||
self.assertEqual(migrated["id"], created["id"])
|
||||
self.assertEqual(db.get_setting("radarr_api_key"), "legacy-plaintext-key")
|
||||
with db._connect() as conn:
|
||||
invite_code = conn.execute(
|
||||
"SELECT code FROM signup_invites WHERE id = ?", (int(created["id"]),)
|
||||
).fetchone()[0]
|
||||
stored_setting = conn.execute(
|
||||
"SELECT value FROM settings WHERE key = 'radarr_api_key'"
|
||||
).fetchone()[0]
|
||||
self.assertTrue(invite_code.startswith("sha256:"))
|
||||
self.assertTrue(stored_setting.startswith("enc:v1:"))
|
||||
|
||||
def test_legacy_password_hash_is_replaced_with_argon2(self) -> None:
|
||||
password = "Example-password123!"
|
||||
db.create_user("legacy", password)
|
||||
legacy_hash = CryptContext(schemes=["pbkdf2_sha256"]).hash(password)
|
||||
with db._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash = ? WHERE username = ?",
|
||||
(legacy_hash, "legacy"),
|
||||
)
|
||||
|
||||
self.assertIsNotNone(db.verify_user_password("legacy", password))
|
||||
self.assertTrue(db.get_user_by_username("legacy")["password_hash"].startswith("$argon2"))
|
||||
|
||||
def test_auth_version_revokes_existing_token(self) -> None:
|
||||
db.create_user("viewer", "Example-password123!")
|
||||
user = db.get_user_by_username("viewer")
|
||||
token = create_access_token(
|
||||
"viewer", "user", auth_version=int(user["auth_version"])
|
||||
)
|
||||
self.assertEqual(_load_current_user_from_token(token)["username"], "viewer")
|
||||
|
||||
db.increment_user_auth_version("viewer")
|
||||
with self.assertRaises(HTTPException) as context:
|
||||
_load_current_user_from_token(token)
|
||||
self.assertEqual(context.exception.status_code, 401)
|
||||
|
||||
async def test_request_mutations_require_owner_or_admin(self) -> None:
|
||||
runtime = SimpleNamespace(
|
||||
jellyseerr_base_url="http://seerr.test", jellyseerr_api_key="secret"
|
||||
)
|
||||
client = SimpleNamespace(
|
||||
configured=lambda: True,
|
||||
get_request=AsyncMock(
|
||||
return_value={"id": 42, "requestedBy": {"username": "owner"}}
|
||||
),
|
||||
)
|
||||
with patch.object(requests_router, "JellyseerrClient", return_value=client):
|
||||
with self.assertRaises(HTTPException) as context:
|
||||
await requests_router._ensure_request_mutation_access(
|
||||
runtime, 42, {"username": "someone-else", "role": "user"}
|
||||
)
|
||||
self.assertEqual(context.exception.status_code, 403)
|
||||
owned = await requests_router._ensure_request_mutation_access(
|
||||
runtime, 42, {"username": "owner", "role": "user"}
|
||||
)
|
||||
self.assertEqual(owned["id"], 42)
|
||||
|
||||
self.assertIsNone(
|
||||
await requests_router._ensure_request_mutation_access(
|
||||
SimpleNamespace(), 42, {"username": "admin", "role": "admin"}
|
||||
)
|
||||
)
|
||||
|
||||
def test_account_deletion_removes_or_anonymizes_personal_data(self) -> None:
|
||||
db.create_user(
|
||||
"viewer", "Example-password123!", email="viewer@example.test"
|
||||
)
|
||||
user = db.get_user_by_username("viewer")
|
||||
now = "2026-09-17T00:00:00+00:00"
|
||||
db.upsert_request_cache(
|
||||
42,
|
||||
99,
|
||||
"movie",
|
||||
2,
|
||||
"Example",
|
||||
2026,
|
||||
"viewer",
|
||||
"viewer",
|
||||
int(user["id"]),
|
||||
now,
|
||||
now,
|
||||
'{"requestedBy":{"username":"viewer","email":"viewer@example.test"}}',
|
||||
)
|
||||
with db._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO snapshots (request_id, state, created_at, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
"42",
|
||||
"available",
|
||||
now,
|
||||
'{"requestedBy":{"username":"viewer","email":"viewer@example.test"}}',
|
||||
),
|
||||
)
|
||||
db.save_action("42", "created", "Created", "ok", "Created by viewer")
|
||||
item = db.create_portal_item(
|
||||
kind="issue",
|
||||
title="Example",
|
||||
description="Example",
|
||||
created_by_username="viewer",
|
||||
created_by_id=int(user["id"]),
|
||||
)
|
||||
|
||||
result = db.delete_user_data_by_username("viewer")
|
||||
|
||||
self.assertTrue(result["deleted"])
|
||||
self.assertIsNone(db.get_user_by_username("viewer"))
|
||||
with db._connect() as conn:
|
||||
request_row = conn.execute(
|
||||
"SELECT requested_by, requested_by_id, payload_json FROM requests_cache WHERE request_id = 42"
|
||||
).fetchone()
|
||||
snapshot_json = conn.execute(
|
||||
"SELECT payload_json FROM snapshots WHERE request_id = '42'"
|
||||
).fetchone()[0]
|
||||
action_message = conn.execute(
|
||||
"SELECT message FROM actions WHERE request_id = '42'"
|
||||
).fetchone()[0]
|
||||
portal_owner = conn.execute(
|
||||
"SELECT created_by_username, created_by_id FROM portal_items WHERE id = ?",
|
||||
(item["id"],),
|
||||
).fetchone()
|
||||
self.assertEqual(request_row[0], "Deleted user")
|
||||
self.assertIsNone(request_row[1])
|
||||
self.assertNotIn("viewer", request_row[2].lower())
|
||||
self.assertNotIn("viewer", snapshot_json.lower())
|
||||
self.assertNotIn("viewer", action_message.lower())
|
||||
self.assertTrue(portal_owner[0].startswith("deleted-user-"))
|
||||
self.assertIsNone(portal_owner[1])
|
||||
|
||||
async def test_branding_upload_rejects_oversized_images_before_decode(self) -> None:
|
||||
upload = SimpleNamespace(
|
||||
filename="logo.png",
|
||||
content_type="image/png",
|
||||
read=AsyncMock(return_value=b"x" * (5 * 1024 * 1024 + 1)),
|
||||
)
|
||||
with self.assertRaises(HTTPException) as context:
|
||||
await branding_router.save_branding_image(upload)
|
||||
self.assertEqual(context.exception.status_code, 413)
|
||||
upload.read.assert_awaited_once_with(5 * 1024 * 1024 + 1)
|
||||
|
||||
|
||||
class NetworkSecurityTests(unittest.TestCase):
|
||||
@@ -1208,6 +1402,13 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||
secret = patch.object(settings, 'jwt_secret', 'manual-release-tests-secret-1234567890123456')
|
||||
secret.start()
|
||||
self.addCleanup(secret.stop)
|
||||
access = patch.object(
|
||||
requests_router,
|
||||
"_ensure_request_mutation_access",
|
||||
new=AsyncMock(return_value=None),
|
||||
)
|
||||
access.start()
|
||||
self.addCleanup(access.stop)
|
||||
|
||||
def selection(self, payload, request_id, source):
|
||||
payload['selectionToken'] = requests_router.manual_releases.issue_selection(
|
||||
@@ -1628,6 +1829,16 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
access = patch.object(
|
||||
requests_router,
|
||||
"_ensure_request_mutation_access",
|
||||
new=AsyncMock(return_value=None),
|
||||
)
|
||||
access.start()
|
||||
self.addCleanup(access.stop)
|
||||
|
||||
def test_failed_repair_marks_linked_issue_as_blocked(self) -> None:
|
||||
issue = {"id": 12, "status": "in_progress"}
|
||||
with (
|
||||
@@ -2072,6 +2283,11 @@ class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
|
||||
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
|
||||
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
|
||||
patch.object(requests_router, "BazarrClient", return_value=bazarr),
|
||||
patch.object(
|
||||
requests_router,
|
||||
"_ensure_request_mutation_access",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch.object(requests_router, "save_action"),
|
||||
patch.object(requests_router, "get_portal_item", return_value={
|
||||
"id": 12,
|
||||
@@ -2184,28 +2400,28 @@ class InviteOperationalStateTests(TempDatabaseMixin, unittest.IsolatedAsyncioTes
|
||||
|
||||
async def test_invite_list_reports_automatic_operational_states(self) -> None:
|
||||
ready = db.create_signup_invite(code="READY", recipient_email="ready@example.com")
|
||||
db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com")
|
||||
disabled = db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com")
|
||||
used = db.create_signup_invite(code="USED", max_uses=1, recipient_email="used@example.com")
|
||||
db.increment_signup_invite_use(int(used["id"]))
|
||||
db.create_signup_invite(
|
||||
expired = db.create_signup_invite(
|
||||
code="EXPIRED",
|
||||
expires_at="2000-01-01T00:00:00+00:00",
|
||||
recipient_email="expired@example.com",
|
||||
)
|
||||
db.create_signup_invite(
|
||||
no_profile = db.create_signup_invite(
|
||||
code="NO-PROFILE",
|
||||
profile_id=999,
|
||||
recipient_email="profile@example.com",
|
||||
)
|
||||
|
||||
payload = await admin_router.get_invites()
|
||||
states = {invite["code"]: invite["operational_state"] for invite in payload["invites"]}
|
||||
states = {invite["id"]: invite["operational_state"] for invite in payload["invites"]}
|
||||
|
||||
self.assertEqual(states[ready["code"]], "ready")
|
||||
self.assertEqual(states["DISABLED"], "disabled")
|
||||
self.assertEqual(states["USED"], "exhausted")
|
||||
self.assertEqual(states["EXPIRED"], "expired")
|
||||
self.assertEqual(states["NO-PROFILE"], "profile_unavailable")
|
||||
self.assertEqual(states[ready["id"]], "ready")
|
||||
self.assertEqual(states[disabled["id"]], "disabled")
|
||||
self.assertEqual(states[used["id"]], "exhausted")
|
||||
self.assertEqual(states[expired["id"]], "expired")
|
||||
self.assertEqual(states[no_profile["id"]], "profile_unavailable")
|
||||
self.assertEqual(payload["summary"]["total"], 5)
|
||||
self.assertEqual(payload["summary"]["ready"], 1)
|
||||
self.assertEqual(payload["summary"]["attention"], 4)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from backend.app.config import settings
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -16,6 +16,13 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
|
||||
secret = patch.object(settings, "jwt_secret", "feature-access-tests-only-secret-123456789")
|
||||
secret.start()
|
||||
self.addCleanup(secret.stop)
|
||||
access = patch.object(
|
||||
requests,
|
||||
"_ensure_request_mutation_access",
|
||||
new=AsyncMock(return_value=None),
|
||||
)
|
||||
access.start()
|
||||
self.addCleanup(access.stop)
|
||||
db.create_user('feature-viewer', 'Example-password123!', role='user')
|
||||
db.create_user('feature-admin', 'Example-password123!', role='admin')
|
||||
self.user = db.get_user_by_username('feature-viewer')
|
||||
|
||||
@@ -61,6 +61,15 @@ class ManualPermissionTests(TempDatabaseMixin, unittest.TestCase):
|
||||
|
||||
|
||||
class ManualEpisodeSearchTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
access = patch.object(
|
||||
requests,
|
||||
'_ensure_request_mutation_access',
|
||||
new=AsyncMock(return_value=None),
|
||||
)
|
||||
access.start()
|
||||
self.addCleanup(access.stop)
|
||||
|
||||
async def test_episode_batch_is_bounded_and_exposes_next_page(self):
|
||||
episodes = [{'id': i, 'seasonNumber': 1, 'monitored': True, 'hasFile': False} for i in range(1, 26)]
|
||||
episodes += [{'id': 26, 'seasonNumber': 1, 'monitored': True, 'hasFile': True}]
|
||||
|
||||
Reference in New Issue
Block a user