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 <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-03-04 16:27:36 -08:00
parent 0435375b12
commit ea7db82049
5 changed files with 108 additions and 8 deletions

View file

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

View file

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

View file

@ -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 <route>/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:

View file

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

View file

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