mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
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:
parent
0435375b12
commit
ea7db82049
5 changed files with 108 additions and 8 deletions
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue