fix: rewrite redirect Location headers using X-Forwarded-Host

When LiteLLM is behind a reverse proxy that does not preserve the
original Host header (e.g. Kong with preserve_host: false), Starlette's
Router.redirect_slashes builds redirect URLs using the internal pod IP
instead of the public hostname.

Add an HTTP middleware that intercepts redirect responses (301/302/307/308)
and rewrites the Location header using X-Forwarded-Host and
X-Forwarded-Proto when present. This is a targeted fix that only affects
redirect Location headers, unlike an ASGI scope rewrite which would
change what all handlers see as the host.

The middleware is a no-op when X-Forwarded-Host is absent, so it does not
affect local development or environments without a reverse proxy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-03-08 23:28:12 -07:00
parent 0435375b12
commit 4778351992
2 changed files with 68 additions and 0 deletions

View file

@ -1396,6 +1396,35 @@ app.add_middleware(
app.add_middleware(PrometheusAuthMiddleware)
@app.middleware("http")
async def rewrite_redirect_location(request: Request, call_next: Any) -> Response:
"""
Rewrite Location headers in redirect responses to use the public
hostname from X-Forwarded-Host / X-Forwarded-Proto when available.
This handles cases where the reverse proxy does not preserve the
original Host header (e.g. Kong with preserve_host: false), which
causes Starlette's Router.redirect_slashes to build redirect URLs
using the internal pod IP instead of the public hostname.
Only activates when X-Forwarded-Host is present, so it is a no-op
for local development and environments without a reverse proxy.
"""
response = await call_next(request)
if response.status_code in (301, 302, 307, 308):
location = response.headers.get("location", "")
fwd_host = request.headers.get("x-forwarded-host", "")
fwd_proto = request.headers.get("x-forwarded-proto", "https")
if fwd_host and location:
from urllib.parse import urlparse, urlunparse
parsed = urlparse(location)
if parsed.netloc: # only rewrite absolute URLs
new = parsed._replace(scheme=fwd_proto, netloc=fwd_host)
response.headers["location"] = urlunparse(new)
return response
def mount_swagger_ui():
swagger_directory = os.path.join(current_dir, "swagger")
swagger_path = "/" if server_root_path is None else server_root_path

View file

@ -135,6 +135,45 @@ def test_join_paths_nested_path():
assert result == "http://0.0.0.0:4000/v1/chat/completions"
@pytest.mark.asyncio
async def test_rewrite_redirect_location_with_forwarded_host():
"""Test that redirect Location headers are rewritten using X-Forwarded-Host"""
from starlette.testclient import TestClient
from starlette.responses import RedirectResponse
from litellm.proxy.proxy_server import app
# Create a test client that sends X-Forwarded-Host
client = TestClient(app)
# Hit /ui which will trigger a trailing-slash redirect to /ui/
response = client.get(
"/ui",
headers={
"x-forwarded-host": "external.company.com",
"x-forwarded-proto": "https",
},
follow_redirects=False,
)
if response.status_code in (301, 302, 307, 308):
location = response.headers.get("location", "")
# The Location should use the forwarded host, not an internal IP
assert "external.company.com" in location
assert location.startswith("https://")
@pytest.mark.asyncio
async def test_rewrite_redirect_location_no_forwarded_host():
"""Test that redirect Location headers are NOT rewritten without X-Forwarded-Host"""
from starlette.testclient import TestClient
from litellm.proxy.proxy_server import app
client = TestClient(app)
response = client.get("/ui", follow_redirects=False)
if response.status_code in (301, 302, 307, 308):
location = response.headers.get("location", "")
# Without X-Forwarded-Host, the location should use the original host
assert "external.company.com" not in location
def _patch_today(monkeypatch, year, month, day):
class PatchedDate(real_datetime.date):
@classmethod