From 719974f0b39d9688bc42cff55a2c74ee33485bdd Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 19 Mar 2026 15:35:13 -0700 Subject: [PATCH] fix: validate return_to against control_plane_url to prevent open redirect Add origin validation for the return_to parameter in SSO flow to prevent JWT theft via crafted redirect URLs. Workers must configure control_plane_url in general_settings. --- litellm/proxy/management_endpoints/ui_sso.py | 41 ++++++++++---- .../proxy/management_endpoints/test_ui_sso.py | 53 ++++++++++++++++++- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 611a0e89bf4..5e6aabd70ac 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -16,6 +16,7 @@ import os import secrets from copy import deepcopy from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union, cast +from urllib.parse import urlencode, urlparse if TYPE_CHECKING: import httpx @@ -403,6 +404,7 @@ async def google_login( state=cli_state, ) if return_to is not None and sso_redirect is not None: + SSOAuthenticationHandler._validate_return_to(return_to) sso_redirect.set_cookie( key="litellm_cp_return_to", value=return_to, @@ -1771,6 +1773,34 @@ class SSOAuthenticationHandler: Handler for SSO Authentication across all SSO providers """ + @staticmethod + def _validate_return_to(return_to: str) -> None: + """ + Validate that return_to matches the configured control_plane_url origin. + + Raises HTTPException(400) if: + - control_plane_url is not configured in general_settings + - return_to origin does not match control_plane_url origin + """ + from litellm.proxy.proxy_server import general_settings + + control_plane_url = general_settings.get("control_plane_url") + if control_plane_url is None: + raise HTTPException( + status_code=400, + 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: + raise HTTPException( + status_code=400, + detail="return_to does not match the configured control_plane_url", + ) + @staticmethod async def get_sso_login_redirect( redirect_url: str, @@ -2550,14 +2580,7 @@ class SSOAuthenticationHandler: # Control-plane cross-origin: redirect back to the control plane UI # with the token in the URL (cookie won't work cross-origin) if return_to is not None: - from urllib.parse import urlencode, urlparse - - parsed = urlparse(return_to) - if parsed.scheme not in ("http", "https"): - raise HTTPException( - status_code=400, - detail="return_to must be an HTTP(S) URL", - ) + SSOAuthenticationHandler._validate_return_to(return_to) separator = "&" if "?" in return_to else "?" redirect_url = ( return_to @@ -2565,7 +2588,7 @@ class SSOAuthenticationHandler: + urlencode({"login": "success", "token": jwt_token}) ) verbose_proxy_logger.info( - f"Cross-origin SSO: redirecting to control plane at {parsed.netloc}" + "Cross-origin SSO: redirecting to control plane" ) redirect_response = RedirectResponse(url=redirect_url, status_code=303) redirect_response.delete_cookie("litellm_cp_return_to") 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 d43b2c4ba05..d48dcc52f59 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest -from fastapi import Request +from fastapi import HTTPException, Request from litellm._uuid import uuid @@ -5160,3 +5160,54 @@ def test_generic_response_convertor_extra_attributes_missing_field(monkeypatch): assert result.extra_fields["missing_field"] is None assert result.extra_fields["another_missing"] is None + +class TestValidateReturnTo: + """Tests for SSOAuthenticationHandler._validate_return_to""" + + def test_rejects_when_no_control_plane_url_configured(self, monkeypatch): + """return_to should be rejected if control_plane_url is not in general_settings.""" + monkeypatch.setattr( + "litellm.proxy.proxy_server.general_settings", {} + ) + with pytest.raises(HTTPException) as exc_info: + SSOAuthenticationHandler._validate_return_to("https://cp.example.com/ui") + assert exc_info.value.status_code == 400 + assert "not configured" in exc_info.value.detail + + def test_allows_matching_origin(self, monkeypatch): + """return_to matching the configured control_plane_url origin should pass.""" + 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?page=models") + + def test_allows_matching_origin_with_trailing_slash(self, monkeypatch): + """Trailing slash on control_plane_url should not affect origin comparison.""" + monkeypatch.setattr( + "litellm.proxy.proxy_server.general_settings", + {"control_plane_url": "https://cp.example.com/"}, + ) + SSOAuthenticationHandler._validate_return_to("https://cp.example.com/ui") + + def test_rejects_prefix_attack(self, monkeypatch): + """return_to like cp.example.com.evil.com must be rejected (not just prefix match).""" + monkeypatch.setattr( + "litellm.proxy.proxy_server.general_settings", + {"control_plane_url": "https://cp.example.com"}, + ) + with pytest.raises(HTTPException) as exc_info: + SSOAuthenticationHandler._validate_return_to("https://cp.example.com.evil.com/steal") + assert exc_info.value.status_code == 400 + + def test_rejects_different_origin(self, monkeypatch): + """return_to pointing to a completely different domain should be rejected.""" + monkeypatch.setattr( + "litellm.proxy.proxy_server.general_settings", + {"control_plane_url": "https://cp.example.com"}, + ) + with pytest.raises(HTTPException) as exc_info: + SSOAuthenticationHandler._validate_return_to("https://evil.com/phish") + assert exc_info.value.status_code == 400 +