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.
This commit is contained in:
Ryan Crabbe 2026-03-19 15:35:13 -07:00
parent 5543718caf
commit 719974f0b3
2 changed files with 84 additions and 10 deletions

View file

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

View file

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