Skip to content

Commit ea7db82

Browse files
yuneng-jiangclaude
andcommitted
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>
1 parent 0435375 commit ea7db82

File tree

5 files changed

+108
-8
lines changed

5 files changed

+108
-8
lines changed

litellm/proxy/management_endpoints/ui_sso.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1883,7 +1883,9 @@ def get_redirect_url_for_sso(
18831883
"""
18841884
from litellm.proxy.utils import get_custom_url
18851885

1886-
redirect_url = get_custom_url(request_base_url=str(request.base_url))
1886+
redirect_url = get_custom_url(
1887+
request_base_url=str(request.base_url), request=request
1888+
)
18871889
if redirect_url.endswith("/"):
18881890
redirect_url += sso_callback_route
18891891
else:
@@ -2313,7 +2315,7 @@ async def get_redirect_response_from_openid( # noqa: PLR0915
23132315
get_disabled_non_admin_personal_key_creation()
23142316
)
23152317
litellm_dashboard_ui = get_custom_url(
2316-
request_base_url=str(request.base_url), route="ui/"
2318+
request_base_url=str(request.base_url), route="ui/", request=request
23172319
)
23182320

23192321
if get_secret_bool("EXPERIMENTAL_UI_LOGIN"):

litellm/proxy/proxy_cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ def _get_default_unvicorn_init_args(
138138
"app": "litellm.proxy.proxy_server:app",
139139
"host": host,
140140
"port": port,
141+
"proxy_headers": True,
142+
"forwarded_allow_ips": "*",
141143
}
142144
if log_config is not None:
143145
print(f"Using log_config: {log_config}") # noqa

litellm/proxy/proxy_server.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,7 +1318,35 @@ def _try_populate_ui_directory(
13181318
)
13191319
# print(f"mounted _next at {server_root_path}/ui/_next")
13201320

1321-
app.mount("/ui", StaticFiles(directory=ui_path, html=True), name="ui")
1321+
class NoTrailingSlashRedirectStaticFiles(StaticFiles):
1322+
"""
1323+
StaticFiles subclass that serves directory index.html directly
1324+
without issuing a trailing-slash 302 redirect.
1325+
1326+
This avoids redirect issues behind reverse proxies where
1327+
Starlette constructs the redirect Location from the internal
1328+
Host header / scheme, causing redirects to internal pod IPs.
1329+
"""
1330+
1331+
async def get_response(self, path: str, scope: Any) -> Response:
1332+
response = await super().get_response(path, scope)
1333+
if response.status_code in (301, 302, 307, 308):
1334+
# This is a trailing-slash redirect for a directory.
1335+
# Serve the index.html directly instead.
1336+
trailing_path = path if path.endswith("/") else path + "/"
1337+
modified_scope = dict(scope)
1338+
modified_scope["path"] = scope["path"].rstrip("/") + "/"
1339+
try:
1340+
return await super().get_response(trailing_path, modified_scope)
1341+
except Exception:
1342+
pass
1343+
return response
1344+
1345+
app.mount(
1346+
"/ui",
1347+
NoTrailingSlashRedirectStaticFiles(directory=ui_path, html=True),
1348+
name="ui",
1349+
)
13221350

13231351
def _restructure_ui_html_files(ui_root: str) -> None:
13241352
"""Ensure each exported HTML route is available as <route>/index.html."""
@@ -10357,7 +10385,7 @@ async def fallback_login(request: Request):
1035710385
from litellm.proxy.proxy_server import ui_link
1035810386

1035910387
# get url from request
10360-
redirect_url = get_custom_url(str(request.base_url))
10388+
redirect_url = get_custom_url(str(request.base_url), request=request)
1036110389
ui_username = os.getenv("UI_USERNAME")
1036210390
if redirect_url.endswith("/"):
1036310391
redirect_url += "sso/callback"
@@ -10417,7 +10445,7 @@ async def login(request: Request): # noqa: PLR0915
1041710445
)
1041810446

1041910447
# Build redirect URL
10420-
litellm_dashboard_ui = get_custom_url(str(request.base_url))
10448+
litellm_dashboard_ui = get_custom_url(str(request.base_url), request=request)
1042110449
if litellm_dashboard_ui.endswith("/"):
1042210450
litellm_dashboard_ui += "ui/"
1042310451
else:
@@ -10464,7 +10492,7 @@ async def login_v2(request: Request): # noqa: PLR0915
1046410492
algorithm="HS256",
1046510493
)
1046610494

10467-
litellm_dashboard_ui = get_custom_url(str(request.base_url))
10495+
litellm_dashboard_ui = get_custom_url(str(request.base_url), request=request)
1046810496
if litellm_dashboard_ui.endswith("/"):
1046910497
litellm_dashboard_ui += "ui/"
1047010498
else:
@@ -10585,7 +10613,7 @@ async def onboarding(invite_link: str, request: Request):
1058510613
)
1058610614
key = response["token"] # type: ignore
1058710615

10588-
litellm_dashboard_ui = get_custom_url(str(request.base_url))
10616+
litellm_dashboard_ui = get_custom_url(str(request.base_url), request=request)
1058910617
if litellm_dashboard_ui.endswith("/"):
1059010618
litellm_dashboard_ui += "ui/onboarding"
1059110619
else:

litellm/proxy/utils.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4735,11 +4735,24 @@ def join_paths(base_path: str, route: str) -> str:
47354735
return final_path
47364736

47374737

4738-
def get_custom_url(request_base_url: str, route: Optional[str] = None) -> str:
4738+
def get_custom_url(
4739+
request_base_url: str,
4740+
route: Optional[str] = None,
4741+
request: Optional[Any] = None,
4742+
) -> str:
47394743
# Use environment variable value, otherwise use URL from request
47404744
server_base_url = get_proxy_base_url()
47414745
if server_base_url is not None:
47424746
base_url = server_base_url
4747+
elif request is not None:
4748+
# Use X-Forwarded-Host/Proto if present (reverse proxy scenario)
4749+
forwarded_proto = request.headers.get("x-forwarded-proto", "")
4750+
forwarded_host = request.headers.get("x-forwarded-host", "")
4751+
if forwarded_host:
4752+
scheme = forwarded_proto or "https"
4753+
base_url = f"{scheme}://{forwarded_host}"
4754+
else:
4755+
base_url = request_base_url
47434756
else:
47444757
base_url = request_base_url
47454758

tests/test_litellm/proxy/test_proxy_utils.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,61 @@ def test_join_paths_nested_path():
135135
assert result == "http://0.0.0.0:4000/v1/chat/completions"
136136

137137

138+
def test_get_custom_url_with_forwarded_headers():
139+
"""Test that get_custom_url uses X-Forwarded-Host/Proto when request is provided"""
140+
mock_request = MagicMock()
141+
mock_request.headers = {
142+
"x-forwarded-proto": "https",
143+
"x-forwarded-host": "external.company.com",
144+
}
145+
url = get_custom_url(
146+
request_base_url="http://10.0.0.5:4000",
147+
route="ui/",
148+
request=mock_request,
149+
)
150+
assert url == "https://external.company.com/ui/"
151+
152+
153+
def test_get_custom_url_with_forwarded_host_only():
154+
"""Test that get_custom_url defaults to https when only X-Forwarded-Host is set"""
155+
mock_request = MagicMock()
156+
mock_request.headers = {"x-forwarded-host": "external.company.com"}
157+
url = get_custom_url(
158+
request_base_url="http://10.0.0.5:4000",
159+
route="ui/",
160+
request=mock_request,
161+
)
162+
assert url == "https://external.company.com/ui/"
163+
164+
165+
def test_get_custom_url_no_forwarded_headers():
166+
"""Test that get_custom_url falls back to request_base_url without forwarded headers"""
167+
mock_request = MagicMock()
168+
mock_request.headers = {}
169+
url = get_custom_url(
170+
request_base_url="http://10.0.0.5:4000",
171+
route="ui/",
172+
request=mock_request,
173+
)
174+
assert url == "http://10.0.0.5:4000/ui/"
175+
176+
177+
def test_get_custom_url_proxy_base_url_takes_priority(monkeypatch):
178+
"""Test that PROXY_BASE_URL takes priority over forwarded headers"""
179+
monkeypatch.setenv("PROXY_BASE_URL", "https://configured.company.com")
180+
mock_request = MagicMock()
181+
mock_request.headers = {
182+
"x-forwarded-proto": "https",
183+
"x-forwarded-host": "external.company.com",
184+
}
185+
url = get_custom_url(
186+
request_base_url="http://10.0.0.5:4000",
187+
route="ui/",
188+
request=mock_request,
189+
)
190+
assert url == "https://configured.company.com/ui/"
191+
192+
138193
def _patch_today(monkeypatch, year, month, day):
139194
class PatchedDate(real_datetime.date):
140195
@classmethod

0 commit comments

Comments
 (0)