fix: gate /v3/login behind control_plane_url and fix RFC 3986 hostname comparison

- /v3/login now returns 404 unless control_plane_url is configured
- _validate_return_to uses urlparse().hostname for case-insensitive comparison
- Add rejection test for /v3/login without control_plane_url
- Add case-insensitive hostname test for _validate_return_to
This commit is contained in:
Ryan Crabbe 2026-03-19 16:00:19 -07:00
parent 719974f0b3
commit f8d1be2e9b
4 changed files with 45 additions and 8 deletions

View file

@ -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",

View file

@ -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"))

View file

@ -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")

View file

@ -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)