From ea7db82049302855f7b0e6e191c31e9beddf7dde Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 4 Mar 2026 16:27:36 -0800 Subject: [PATCH] fix: UI redirects to internal pod IPs behind reverse proxies Enable uvicorn ProxyHeadersMiddleware to rewrite ASGI scope from X-Forwarded-Proto/For headers. Add X-Forwarded-Host support to get_custom_url for login/SSO redirects. Replace StaticFiles with a subclass that serves index.html directly instead of issuing trailing- slash 302 redirects that use internal hostnames. Co-Authored-By: Claude Opus 4.6 --- litellm/proxy/management_endpoints/ui_sso.py | 6 ++- litellm/proxy/proxy_cli.py | 2 + litellm/proxy/proxy_server.py | 38 ++++++++++++-- litellm/proxy/utils.py | 15 +++++- tests/test_litellm/proxy/test_proxy_utils.py | 55 ++++++++++++++++++++ 5 files changed, 108 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 17e266bdeae..44374ca1f87 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -1883,7 +1883,9 @@ class SSOAuthenticationHandler: """ from litellm.proxy.utils import get_custom_url - redirect_url = get_custom_url(request_base_url=str(request.base_url)) + redirect_url = get_custom_url( + request_base_url=str(request.base_url), request=request + ) if redirect_url.endswith("/"): redirect_url += sso_callback_route else: @@ -2313,7 +2315,7 @@ class SSOAuthenticationHandler: get_disabled_non_admin_personal_key_creation() ) litellm_dashboard_ui = get_custom_url( - request_base_url=str(request.base_url), route="ui/" + request_base_url=str(request.base_url), route="ui/", request=request ) if get_secret_bool("EXPERIMENTAL_UI_LOGIN"): diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index e91447af895..527a9b0967d 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -138,6 +138,8 @@ class ProxyInitializationHelpers: "app": "litellm.proxy.proxy_server:app", "host": host, "port": port, + "proxy_headers": True, + "forwarded_allow_ips": "*", } if log_config is not None: print(f"Using log_config: {log_config}") # noqa diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index aeb502599dc..993386c6dc8 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1318,7 +1318,35 @@ try: ) # print(f"mounted _next at {server_root_path}/ui/_next") - app.mount("/ui", StaticFiles(directory=ui_path, html=True), name="ui") + class NoTrailingSlashRedirectStaticFiles(StaticFiles): + """ + StaticFiles subclass that serves directory index.html directly + without issuing a trailing-slash 302 redirect. + + This avoids redirect issues behind reverse proxies where + Starlette constructs the redirect Location from the internal + Host header / scheme, causing redirects to internal pod IPs. + """ + + async def get_response(self, path: str, scope: Any) -> Response: + response = await super().get_response(path, scope) + if response.status_code in (301, 302, 307, 308): + # This is a trailing-slash redirect for a directory. + # Serve the index.html directly instead. + trailing_path = path if path.endswith("/") else path + "/" + modified_scope = dict(scope) + modified_scope["path"] = scope["path"].rstrip("/") + "/" + try: + return await super().get_response(trailing_path, modified_scope) + except Exception: + pass + return response + + app.mount( + "/ui", + NoTrailingSlashRedirectStaticFiles(directory=ui_path, html=True), + name="ui", + ) def _restructure_ui_html_files(ui_root: str) -> None: """Ensure each exported HTML route is available as /index.html.""" @@ -10357,7 +10385,7 @@ async def fallback_login(request: Request): from litellm.proxy.proxy_server import ui_link # get url from request - redirect_url = get_custom_url(str(request.base_url)) + redirect_url = get_custom_url(str(request.base_url), request=request) ui_username = os.getenv("UI_USERNAME") if redirect_url.endswith("/"): redirect_url += "sso/callback" @@ -10417,7 +10445,7 @@ async def login(request: Request): # noqa: PLR0915 ) # Build redirect URL - litellm_dashboard_ui = get_custom_url(str(request.base_url)) + litellm_dashboard_ui = get_custom_url(str(request.base_url), request=request) if litellm_dashboard_ui.endswith("/"): litellm_dashboard_ui += "ui/" else: @@ -10464,7 +10492,7 @@ async def login_v2(request: Request): # noqa: PLR0915 algorithm="HS256", ) - litellm_dashboard_ui = get_custom_url(str(request.base_url)) + litellm_dashboard_ui = get_custom_url(str(request.base_url), request=request) if litellm_dashboard_ui.endswith("/"): litellm_dashboard_ui += "ui/" else: @@ -10585,7 +10613,7 @@ async def onboarding(invite_link: str, request: Request): ) key = response["token"] # type: ignore - litellm_dashboard_ui = get_custom_url(str(request.base_url)) + litellm_dashboard_ui = get_custom_url(str(request.base_url), request=request) if litellm_dashboard_ui.endswith("/"): litellm_dashboard_ui += "ui/onboarding" else: diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 46bea3809aa..61c3f81a976 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -4735,11 +4735,24 @@ def join_paths(base_path: str, route: str) -> str: return final_path -def get_custom_url(request_base_url: str, route: Optional[str] = None) -> str: +def get_custom_url( + request_base_url: str, + route: Optional[str] = None, + request: Optional[Any] = None, +) -> str: # Use environment variable value, otherwise use URL from request server_base_url = get_proxy_base_url() if server_base_url is not None: base_url = server_base_url + elif request is not None: + # Use X-Forwarded-Host/Proto if present (reverse proxy scenario) + forwarded_proto = request.headers.get("x-forwarded-proto", "") + forwarded_host = request.headers.get("x-forwarded-host", "") + if forwarded_host: + scheme = forwarded_proto or "https" + base_url = f"{scheme}://{forwarded_host}" + else: + base_url = request_base_url else: base_url = request_base_url diff --git a/tests/test_litellm/proxy/test_proxy_utils.py b/tests/test_litellm/proxy/test_proxy_utils.py index 7deda21c215..0709e0b05ae 100644 --- a/tests/test_litellm/proxy/test_proxy_utils.py +++ b/tests/test_litellm/proxy/test_proxy_utils.py @@ -135,6 +135,61 @@ def test_join_paths_nested_path(): assert result == "http://0.0.0.0:4000/v1/chat/completions" +def test_get_custom_url_with_forwarded_headers(): + """Test that get_custom_url uses X-Forwarded-Host/Proto when request is provided""" + mock_request = MagicMock() + mock_request.headers = { + "x-forwarded-proto": "https", + "x-forwarded-host": "external.company.com", + } + url = get_custom_url( + request_base_url="http://10.0.0.5:4000", + route="ui/", + request=mock_request, + ) + assert url == "https://external.company.com/ui/" + + +def test_get_custom_url_with_forwarded_host_only(): + """Test that get_custom_url defaults to https when only X-Forwarded-Host is set""" + mock_request = MagicMock() + mock_request.headers = {"x-forwarded-host": "external.company.com"} + url = get_custom_url( + request_base_url="http://10.0.0.5:4000", + route="ui/", + request=mock_request, + ) + assert url == "https://external.company.com/ui/" + + +def test_get_custom_url_no_forwarded_headers(): + """Test that get_custom_url falls back to request_base_url without forwarded headers""" + mock_request = MagicMock() + mock_request.headers = {} + url = get_custom_url( + request_base_url="http://10.0.0.5:4000", + route="ui/", + request=mock_request, + ) + assert url == "http://10.0.0.5:4000/ui/" + + +def test_get_custom_url_proxy_base_url_takes_priority(monkeypatch): + """Test that PROXY_BASE_URL takes priority over forwarded headers""" + monkeypatch.setenv("PROXY_BASE_URL", "https://configured.company.com") + mock_request = MagicMock() + mock_request.headers = { + "x-forwarded-proto": "https", + "x-forwarded-host": "external.company.com", + } + url = get_custom_url( + request_base_url="http://10.0.0.5:4000", + route="ui/", + request=mock_request, + ) + assert url == "https://configured.company.com/ui/" + + def _patch_today(monkeypatch, year, month, day): class PatchedDate(real_datetime.date): @classmethod