"""Managed first-install origin claims use a token, not request routing headers.""" from concurrent.futures import ThreadPoolExecutor import os from pathlib import Path import sqlite3 import tempfile from threading import Barrier import unittest from unittest.mock import patch from fastapi.testclient import TestClient from backend.app import auth, db, main from backend.app.config import settings from backend.app.installation_origin import normalize_application_origin from backend.app.security import create_access_token from backend.app.services import setup from backend.app.services.public_urls import magent_public_url from backend.app.services.request_origins import can_claim_initial_origin, is_allowed_request_origin PUBLIC_ORIGIN = "https://watch.example.test" LOCAL_ORIGIN = "http://localhost:3000" SETUP_TOKEN = "managed-origin-operator-token-for-tests-only-1234567890" ADMIN_PASSWORD = "Managed-origin-password-for-tests!123" class ApplicationOriginNormalizationTests(unittest.TestCase): def test_exact_origins_are_canonicalized(self): for value, expected in ( (PUBLIC_ORIGIN, PUBLIC_ORIGIN), ("HTTPS://WATCH.EXAMPLE.TEST:443/", PUBLIC_ORIGIN), ("http://magent.lan:80/", "http://magent.lan"), ("http://192.0.2.10:3000", "http://192.0.2.10:3000"), ("http://[fd00::10]:3000/", "http://[fd00::10]:3000"), ): with self.subTest(value=value): self.assertEqual(normalize_application_origin(value), expected) def test_non_origins_and_ambiguous_values_are_rejected(self): for value in ( "", "null", "*", "magent.lan", "//magent.lan", "https:/magent.lan", "ftp://magent.lan", "https://user@magent.lan", "https://user:secret@magent.lan", "https://magent.lan/path", "https://magent.lan/../", "https://magent.lan?", "https://magent.lan#", "https://magent.lan?token=1", "https://magent.lan#fragment", "https://*.magent.lan", "https://magent.lan:0", "https://magent.lan:65536", "https://magent.lan:", "https://magent.lan\\path", " https://magent.lan", "https://magent.\tlan", "https://magent.lan\x00", ): with self.subTest(value=repr(value)), self.assertRaises(ValueError): normalize_application_origin(value) class ManagedSetupOriginTests(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) self.addCleanup(temporary.cleanup) self.enterContext(patch.dict(os.environ, {"MAGENT_RUNTIME_MANAGED": "1"})) for name, value in { "sqlite_path": str(Path(temporary.name) / "managed-setup.db"), "sqlite_journal_mode": "DELETE", "jwt_secret": "managed-origin-test-signing-key-at-least-32-characters", "settings_encryption_key": None, "setup_token": SETUP_TOKEN, "admin_username": "unused-environment-admin", "admin_password": "", "cors_allow_origin": LOCAL_ORIGIN, "magent_application_url": None, "magent_proxy_enabled": False, "magent_proxy_base_url": None, "auth_cookie_domain": None, "auth_cookie_secure": False, }.items(): self.enterContext(patch.object(settings, name, value)) setup.initialize_setup_state() db.init_db() # No production startup: no external workers, listeners or real data. self.client = TestClient(main.app, base_url=PUBLIC_ORIGIN) self.addCleanup(self.client.close) def bootstrap(self, *, origin=PUBLIC_ORIGIN, headers=None, **changes): request_headers = {} if origin is None else {"Origin": origin} request_headers.update(headers or {}) return self.client.post("/setup/bootstrap", headers=request_headers, json={ "setup_token": SETUP_TOKEN, "username": "first-admin", "password": ADMIN_PASSWORD, "application_url": PUBLIC_ORIGIN, **changes, }) def assert_unclaimed(self): self.assertFalse(db.has_admin_user()) self.assertIsNone(db.get_setting("magent_application_url")) self.assertEqual(setup.get_setup_state()["step"], "administrator") def preflight(self, origin): return self.client.options("/auth/login", headers={ "Origin": origin, "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "Content-Type", }) def admin_headers(self, origin=PUBLIC_ORIGIN): return { "Origin": origin, "Authorization": f"Bearer {create_access_token('first-admin', 'admin')}", } def test_fresh_managed_install_does_not_trust_any_origin_before_claim(self): self.assertTrue(can_claim_initial_origin()) for origin in (PUBLIC_ORIGIN, LOCAL_ORIGIN, "https://evil.example.test"): with self.subTest(origin=origin): self.assertFalse(is_allowed_request_origin(origin)) self.assert_unclaimed() def test_valid_operator_claim_creates_admin_and_persists_url_atomically(self): response = self.bootstrap(application_url="HTTPS://WATCH.EXAMPLE.TEST:443/") self.assertEqual(response.status_code, 201, response.text) self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN) self.assertIsNotNone(db.verify_user_password("first-admin", ADMIN_PASSWORD)) self.assertEqual(setup.get_setup_state()["step"], "apps") self.assertFalse(can_claim_initial_origin()) self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN)) self.assertFalse(is_allowed_request_origin(LOCAL_ORIGIN)) def test_missing_or_null_application_url_does_not_claim(self): response = self.bootstrap(application_url=None) self.assertEqual(response.status_code, 400, response.text) response = self.client.post("/setup/bootstrap", headers={"Origin": PUBLIC_ORIGIN}, json={ "setup_token": SETUP_TOKEN, "username": "first-admin", "password": ADMIN_PASSWORD, }) self.assertEqual(response.status_code, 400, response.text) self.assert_unclaimed() def test_missing_browser_origin_does_not_claim(self): response = self.bootstrap(origin=None) self.assertEqual(response.status_code, 403, response.text) self.assert_unclaimed() def test_wrong_token_cannot_claim_even_when_url_matches_evil_origin(self): for origin in (PUBLIC_ORIGIN, "https://evil.example.test"): with self.subTest(origin=origin): response = self.bootstrap(origin=origin, application_url=origin, setup_token="wrong-token") self.assertEqual(response.status_code, 403, response.text) self.assertNotIn(SETUP_TOKEN, response.text) self.assert_unclaimed() def test_different_origin_and_spoofed_routing_headers_cannot_claim(self): response = self.bootstrap(origin="https://evil.example.test", headers={ "Host": "watch.example.test", "X-Forwarded-Host": "watch.example.test", "X-Forwarded-Proto": "https", "Sec-Fetch-Site": "same-origin", }) self.assertEqual(response.status_code, 403, response.text) self.assert_unclaimed() def test_invalid_application_urls_never_claim_or_echo_secrets(self): with patch.object(setup, "consume_bootstrap_attempt", return_value=None): for value in ("", "https://user:secret@watch.example.test", PUBLIC_ORIGIN + "/path", PUBLIC_ORIGIN + "?", "javascript:alert(1)", "//watch.example.test"): with self.subTest(value=value): response = self.bootstrap(application_url=value) self.assertEqual(response.status_code, 400, response.text) self.assertNotIn(SETUP_TOKEN, response.text) self.assertNotIn(ADMIN_PASSWORD, response.text) self.assert_unclaimed() def test_other_state_changing_endpoints_do_not_inherit_bootstrap_origin_exception(self): for path, method in (("/auth/login", "post"), ("/auth/jellyfin/login", "post"), ("/setup/complete", "post"), ("/setup/state", "put"), ("/setup/bootstrap/", "post"), ("/admin/settings", "put")): for origin in (PUBLIC_ORIGIN, LOCAL_ORIGIN, "https://evil.example.test"): with self.subTest(path=path, origin=origin): response = getattr(self.client, method)(path, json={}, headers={"Origin": origin}, follow_redirects=False) self.assertEqual(response.status_code, 403, response.text) self.assertEqual(response.json()["detail"], "Cross-origin state change rejected") self.assert_unclaimed() def test_existing_admin_prevents_reclaim_and_url_replacement(self): self.assertEqual(self.bootstrap().status_code, 201) response = self.bootstrap(username="second-admin") self.assertEqual(response.status_code, 409, response.text) response = self.bootstrap(origin="https://evil.example.test", application_url="https://evil.example.test") self.assertEqual(response.status_code, 403, response.text) self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN) self.assertEqual(len(db.get_all_users()), 1) def test_completed_install_cannot_reopen_origin_claim_after_admin_removal(self): self.assertEqual(self.bootstrap().status_code, 201) setup.complete_setup() with db._connect() as connection: connection.execute("DELETE FROM users") connection.execute("DELETE FROM settings WHERE key='magent_application_url'") self.assertFalse(can_claim_initial_origin()) self.assertEqual(self.bootstrap().status_code, 403) self.assertFalse(db.has_admin_user()) def test_setting_write_failure_rolls_back_admin_and_setup_progress(self): with db._connect() as connection: connection.execute("""CREATE TRIGGER reject_origin BEFORE INSERT ON settings WHEN NEW.key = 'magent_application_url' BEGIN SELECT RAISE(ABORT, 'synthetic origin storage failure'); END""") with self.assertRaises(sqlite3.IntegrityError): setup.bootstrap_administrator(SETUP_TOKEN, "first-admin", ADMIN_PASSWORD, application_url=PUBLIC_ORIGIN) self.assert_unclaimed() def test_concurrent_claims_keep_the_winning_admin_and_origin_together(self): barrier = Barrier(4) def synchronized_hash(_): barrier.wait(timeout=10) return "test-only-precomputed-hash" def claim(number): try: setup.bootstrap_administrator( SETUP_TOKEN, f"owner-{number}", ADMIN_PASSWORD, application_url=f"https://owner-{number}.example.test", ) return number except setup.SetupUnavailableError: return None with patch.object(setup, "hash_password", side_effect=synchronized_hash): with ThreadPoolExecutor(max_workers=4) as executor: winners = [number for number in executor.map(claim, range(4)) if number is not None] self.assertEqual(len(winners), 1) self.assertEqual([user["username"] for user in db.get_all_users()], [f"owner-{winners[0]}"]) self.assertEqual(db.get_setting("magent_application_url"), f"https://owner-{winners[0]}.example.test") def test_first_https_login_uses_secure_cookie_without_restart(self): self.assertEqual(self.bootstrap().status_code, 201) self.assertFalse(settings.auth_cookie_secure) response = self.client.post("/auth/login", headers={"Origin": PUBLIC_ORIGIN}, data={ "username": "first-admin", "password": ADMIN_PASSWORD, }) self.assertEqual(response.status_code, 200, response.text) cookie = next(value for value in response.headers.get_list("set-cookie") if value.startswith(settings.auth_cookie_name + "=")) self.assertIn("Secure", cookie) self.assertIn("HttpOnly", cookie) self.assertEqual(self.client.get("/auth/me").status_code, 200) def test_http_lan_claim_uses_non_secure_cookie_despite_static_secure_default(self): origin = "http://magent.lan:3000" self.assertEqual(self.bootstrap(origin=origin, application_url=origin).status_code, 201) with patch.object(settings, "auth_cookie_secure", True): self.assertFalse(auth._cookie_settings()["secure"]) self.assertTrue(auth._cookie_settings()["httponly"]) as_client = TestClient(main.app, base_url=origin) try: response = as_client.post("/auth/login", headers={"Origin": origin}, data={ "username": "first-admin", "password": ADMIN_PASSWORD, }) self.assertEqual(as_client.get("/auth/me").status_code, 200) finally: as_client.close() self.assertEqual(response.status_code, 200, response.text) cookie = next(value for value in response.headers.get_list("set-cookie") if value.startswith(settings.auth_cookie_name + "=")) self.assertNotIn("Secure", cookie) self.assertIn("HttpOnly", cookie) def test_login_origin_policy_and_saved_url_survive_setup_reinitialization(self): self.assertEqual(self.bootstrap().status_code, 201) setup.initialize_setup_state() db.init_db() self.assertFalse(can_claim_initial_origin()) self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN) for origin in (LOCAL_ORIGIN, "https://evil.example.test"): response = self.client.post("/auth/login", headers={"Origin": origin}, data={ "username": "first-admin", "password": ADMIN_PASSWORD, }) self.assertEqual(response.status_code, 403, response.text) response = self.client.post("/auth/login", headers={"Origin": PUBLIC_ORIGIN}, data={ "username": "first-admin", "password": ADMIN_PASSWORD, }) self.assertEqual(response.status_code, 200, response.text) def test_managed_origin_policy_changes_with_saved_settings(self): self.assertEqual(self.bootstrap().status_code, 201) db.set_setting("magent_application_url", "http://magent.lan:3000") self.assertFalse(is_allowed_request_origin(PUBLIC_ORIGIN)) self.assertFalse(is_allowed_request_origin(LOCAL_ORIGIN)) self.assertTrue(is_allowed_request_origin("http://magent.lan:3000")) self.assertFalse(auth._cookie_settings()["secure"]) def test_unclaimed_install_does_not_grant_cors_to_any_browser_origin(self): for origin in (PUBLIC_ORIGIN, LOCAL_ORIGIN, "https://evil.example.test"): with self.subTest(origin=origin): response = self.preflight(origin) self.assertEqual(response.status_code, 400, response.text) self.assertNotIn("access-control-allow-origin", response.headers) response = self.client.get("/setup/status", headers={"Origin": origin}) self.assertEqual(response.status_code, 200, response.text) self.assertNotIn("access-control-allow-origin", response.headers) self.assert_unclaimed() def test_claim_immediately_updates_cors_preflights_and_response_headers(self): self.assertEqual(self.bootstrap().status_code, 201) response = self.preflight(PUBLIC_ORIGIN) self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.headers["access-control-allow-origin"], PUBLIC_ORIGIN) self.assertEqual(response.headers["access-control-allow-credentials"], "true") self.assertIn("Origin", response.headers["vary"]) response = self.client.get("/setup/status", headers={"Origin": PUBLIC_ORIGIN}) self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.headers["access-control-allow-origin"], PUBLIC_ORIGIN) self.assertEqual(response.headers["access-control-allow-credentials"], "true") def test_claimed_install_denies_localhost_and_foreign_cors_preflights_and_reads(self): self.assertEqual(self.bootstrap().status_code, 201) for origin in (LOCAL_ORIGIN, "https://evil.example.test", PUBLIC_ORIGIN + "/", "null"): with self.subTest(origin=origin): response = self.preflight(origin) self.assertEqual(response.status_code, 400, response.text) self.assertNotIn("access-control-allow-origin", response.headers) response = self.client.get("/setup/status", headers={"Origin": origin}) self.assertEqual(response.status_code, 200, response.text) self.assertNotIn("access-control-allow-origin", response.headers) def test_admin_cannot_blank_or_malform_managed_url_or_partially_save_other_settings(self): self.assertEqual(self.bootstrap().status_code, 201) for value in (None, "", " ", False, 123, [], {}, "javascript:alert(1)", "//watch.example.test", PUBLIC_ORIGIN + "/path", PUBLIC_ORIGIN + "?", "https://user:secret@watch.example.test"): with self.subTest(value=value): response = self.client.put("/admin/settings", headers=self.admin_headers(), json={ "site_login_message": "must-not-be-written", "magent_application_url": value, }) self.assertEqual(response.status_code, 400, response.text) self.assertEqual(db.get_setting("magent_application_url"), PUBLIC_ORIGIN) self.assertIsNone(db.get_setting("site_login_message")) self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN)) self.assertFalse(can_claim_initial_origin()) def test_admin_url_update_is_canonical_and_immediately_replaces_cors_origin(self): self.assertEqual(self.bootstrap().status_code, 201) next_origin = "https://new.example.test" response = self.client.put("/admin/settings", headers=self.admin_headers(), json={ "magent_application_url": "HTTPS://NEW.EXAMPLE.TEST:443/", }) self.assertEqual(response.status_code, 200, response.text) self.assertEqual(db.get_setting("magent_application_url"), next_origin) self.assertEqual(self.preflight(PUBLIC_ORIGIN).status_code, 400) response = self.preflight(next_origin) self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.headers["access-control-allow-origin"], next_origin) response = self.client.get("/setup/status", headers={"Origin": next_origin}) self.assertEqual(response.headers["access-control-allow-origin"], next_origin) response = self.client.put("/admin/settings", headers=self.admin_headers(), json={"site_login_message": "stale"}) self.assertEqual(response.status_code, 403, response.text) response = self.client.put("/admin/settings", headers=self.admin_headers(next_origin), json={"site_login_message": "new"}) self.assertEqual(response.status_code, 200, response.text) self.assertEqual(db.get_setting("site_login_message"), "new") def test_proxy_settings_cannot_replace_managed_origin_or_lower_cookie_security(self): self.assertEqual(self.bootstrap().status_code, 201) db.set_setting("magent_proxy_enabled", "true") db.set_setting("magent_proxy_base_url", "http://proxy.example.test") self.assertEqual(magent_public_url(), PUBLIC_ORIGIN) self.assertTrue(is_allowed_request_origin(PUBLIC_ORIGIN)) self.assertFalse(is_allowed_request_origin("http://proxy.example.test")) self.assertTrue(auth._cookie_settings()["secure"]) self.assertEqual(self.preflight(PUBLIC_ORIGIN).status_code, 200) self.assertEqual(self.preflight("http://proxy.example.test").status_code, 400) def test_unclaimed_managed_url_ignores_legacy_proxy_and_link_fallback(self): db.set_setting("magent_proxy_enabled", "true") db.set_setting("magent_proxy_base_url", "https://proxy.example.test") self.assertEqual(magent_public_url("https://legacy.example.test"), "") self.assertTrue(can_claim_initial_origin()) self.assertFalse(is_allowed_request_origin("https://proxy.example.test")) self.assertEqual(self.preflight("https://proxy.example.test").status_code, 400) if __name__ == "__main__": unittest.main()