mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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:
parent
0435375b12
commit
4778351992
2 changed files with 68 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue