fix(proxy): preserve trusted forwarded protocol chains

This commit is contained in:
Devin AI 2026-11-03 01:07:37 +00:00
parent 1591292d13
commit 06827e398a
2 changed files with 40 additions and 3 deletions

View file

@ -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)

View file

@ -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"}