380 lines
20 KiB
Python
380 lines
20 KiB
Python
"""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()
|