From 06827e398a29d676b0f9af070b5dca5c6bbee205 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:07:37 +0000 Subject: [PATCH] fix(proxy): preserve trusted forwarded protocol chains --- .../middleware/proxy_headers_middleware.py | 15 +++++++++- .../test_proxy_headers_middleware.py | 28 +++++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/middleware/proxy_headers_middleware.py b/litellm/proxy/middleware/proxy_headers_middleware.py index 2cce0b89878..0ab6d217433 100644 --- a/litellm/proxy/middleware/proxy_headers_middleware.py +++ b/litellm/proxy/middleware/proxy_headers_middleware.py @@ -5,6 +5,7 @@ from typing import ( ) from pydantic import TypeAdapter +from starlette.datastructures import Headers from starlette.types import ASGIApp, Receive, Scope, Send from uvicorn._types import ASGI3Application from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware @@ -63,6 +64,18 @@ class TrustedProxyMiddleware: configured=configured, via_trusted_proxy=peer in self._middleware.trusted_hosts, ) - resolved_scope: Final = {**scope, "scheme": context.direct_scheme, PROXY_HEADER_CONTEXT: context} + forwarded_proto: Final = ( + Headers(raw=scope.get("headers", [])).get("x-forwarded-proto", "").split(",", 1)[0].strip().lower() + ) + scheme: Final = ( + forwarded_proto + if context.via_trusted_proxy and forwarded_proto in {"http", "https"} + else context.direct_scheme + ) + resolved_scope: Final = { + **scope, + "scheme": scheme.replace("http", "ws") if scope["type"] == "websocket" else scheme, + PROXY_HEADER_CONTEXT: context, + } handler: Final = cast(ASGIApp, self._middleware) # cast-ok: equivalent ASGI protocol stubs await handler(resolved_scope, receive, send) diff --git a/tests/unit/proxy/middleware/test_proxy_headers_middleware.py b/tests/unit/proxy/middleware/test_proxy_headers_middleware.py index c341be74e55..4acdfa8a7ec 100644 --- a/tests/unit/proxy/middleware/test_proxy_headers_middleware.py +++ b/tests/unit/proxy/middleware/test_proxy_headers_middleware.py @@ -56,6 +56,7 @@ def client_for(settings: Callable[[], Mapping[str, object]], peer: str = "10.0.0 "allowed": _check_valid_ip(["203.0.113.9"], request, use_x_forwarded_for=True)[0], "peer": get_direct_client_ip(request), "scheme": request.url.scheme, + "forwarded_proto": request.headers.get("x-forwarded-proto"), "identity_trusted": identity_trusted, "login_ip": throttle.client_ip, "source_limit": throttle.source_limit, @@ -161,6 +162,28 @@ def test_mcp_without_forwarding_uses_original_peer_but_ui_cookies_use_resolved_s assert result["secure_cookie"] is True +@pytest.mark.parametrize( + "peer,proto,direct_scheme,expected", + [ + ("10.0.0.1", "https, http", "http", "https"), + ("10.0.0.1", " HTTPS , http", "http", "https"), + ("10.0.0.1", "http, https", "http", "http"), + ("10.0.0.1", "invalid, https", "http", "http"), + ("192.0.2.1", "https, http", "http", "http"), + ("192.0.2.1", "http, https", "https", "https"), + ], +) +def test_forwarded_protocol_chains_preserve_cookie_security( + peer: str, proto: str, direct_scheme: str, expected: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("PROXY_BASE_URL", raising=False) + client: Final = client_for(lambda: {"trusted_proxy_ranges": ["10.0.0.0/8"]}, peer) + result: Final = client.get(f"{direct_scheme}://testserver/", headers={"x-forwarded-proto": proto}).json() + assert result["scheme"] == expected + assert result["secure_cookie"] is (expected == "https") + assert result["forwarded_proto"] == proto + + @pytest.mark.parametrize( "mcp_ranges,expected", [(["10.0.0.0/8"], "https://public.example"), (["192.0.2.0/24"], "http://testserver")] ) @@ -205,7 +228,8 @@ def test_config_refresh_does_not_reuse_stale_trust() -> None: assert client.get("/", headers={"x-forwarded-for": "203.0.113.9"}).json() == {"ip": "10.0.0.1"} -def test_websockets_resolve_client_and_scheme_after_lifespan_configuration() -> None: +@pytest.mark.parametrize("proto", ["https", "https, http"]) +def test_websockets_resolve_client_and_scheme_after_lifespan_configuration(proto: str) -> None: @asynccontextmanager async def lifespan(app: Starlette) -> AsyncIterator[None]: app.state.proxy_ranges = ["10.0.0.0/8"] @@ -220,6 +244,6 @@ def test_websockets_resolve_client_and_scheme_after_lifespan_configuration() -> app.add_middleware(TrustedProxyMiddleware, settings=lambda: {"trusted_proxy_ranges": app.state.proxy_ranges}) with TestClient(app, client=("10.0.0.1", 5555)) as client: with client.websocket_connect( - "/", headers={"x-forwarded-for": "203.0.113.9", "x-forwarded-proto": "https"} + "/", headers={"x-forwarded-for": "203.0.113.9", "x-forwarded-proto": proto} ) as ws: assert ws.receive_json() == {"client": ["203.0.113.9", 0], "scheme": "wss"}