diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 5e6aabd70ac..39e015f3928 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -1791,11 +1791,7 @@ class SSOAuthenticationHandler: detail="return_to is not allowed: control_plane_url is not configured", ) - parsed_cp = urlparse(control_plane_url) - parsed_return = urlparse(return_to) - cp_origin = f"{parsed_cp.scheme}://{parsed_cp.netloc}" - return_origin = f"{parsed_return.scheme}://{parsed_return.netloc}" - if cp_origin != return_origin: + if urlparse(return_to).hostname != urlparse(control_plane_url).hostname: raise HTTPException( status_code=400, detail="return_to does not match the configured control_plane_url", diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 48d9d18fe46..73b224d4bba 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -11080,6 +11080,14 @@ async def login_v3(request: Request): # noqa: PLR0915 from litellm.proxy.utils import get_custom_url try: + if not general_settings.get("control_plane_url"): + raise ProxyException( + message="/v3/login is only available on workers with control_plane_url configured", + type=ProxyErrorTypes.not_found_error, + param="control_plane_url", + code=status.HTTP_404_NOT_FOUND, + ) + body = await request.json() username = str(body.get("username")) password = str(body.get("password")) diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index d48dcc52f59..4d531da67ca 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -5211,3 +5211,12 @@ class TestValidateReturnTo: SSOAuthenticationHandler._validate_return_to("https://evil.com/phish") assert exc_info.value.status_code == 400 + def test_case_insensitive_hostname(self, monkeypatch): + """Hostname comparison should be case-insensitive per RFC 3986.""" + monkeypatch.setattr( + "litellm.proxy.proxy_server.general_settings", + {"control_plane_url": "https://CP.Example.COM"}, + ) + # Should not raise + SSOAuthenticationHandler._validate_return_to("https://cp.example.com/ui") + diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index e6269936b5c..f23f5a39353 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -236,8 +236,25 @@ def test_login_v2_returns_json_on_invalid_json_body(monkeypatch): assert isinstance(data["error"], dict) -def test_login_v3_always_includes_token_in_body(monkeypatch): - """v3/login always returns token in body, even without worker_registry.""" +def test_login_v3_rejected_without_control_plane_url(monkeypatch): + """v3/login returns 404 when control_plane_url is not configured.""" + mock_prisma_client = MagicMock() + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key") + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + client = TestClient(app) + response = client.post( + "/v3/login", + json={"username": "alice", "password": "secret"}, + ) + + assert response.status_code == 404 + assert "control_plane_url" in response.json()["error"]["message"] + + +def test_login_v3_includes_token_in_body(monkeypatch): + """v3/login returns token in body when control_plane_url is configured.""" mock_prisma_client = MagicMock() monkeypatch.setattr( "litellm.proxy.auth.login_utils.authenticate_user", @@ -249,7 +266,10 @@ def test_login_v3_always_includes_token_in_body(monkeypatch): ) monkeypatch.setattr("jwt.encode", MagicMock(return_value="signed-token")) monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key") - monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) + monkeypatch.setattr( + "litellm.proxy.proxy_server.general_settings", + {"control_plane_url": "https://cp.example.com"}, + ) monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mock_config = MagicMock() @@ -289,6 +309,10 @@ def test_login_v3_returns_json_on_proxy_exception(monkeypatch): mock_authenticate_user, ) monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key") + monkeypatch.setattr( + "litellm.proxy.proxy_server.general_settings", + {"control_plane_url": "https://cp.example.com"}, + ) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) client = TestClient(app)