Skip to content

Commit eeb64b1

Browse files
authored
Consume duplicate forwarding headers in ProxyHeadersMiddleware (#2971)
1 parent 630f4ac commit eeb64b1

3 files changed

Lines changed: 43 additions & 14 deletions

File tree

docs/deployment/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ or Literals (e.g. `/path/to/socket.sock`). When running from CLI these are confi
270270
!!! Warning "Only trust clients you can actually trust!"
271271
Incorrectly trusting other clients can lead to malicious actors spoofing their apparent client address to your application.
272272

273+
A proxy chain may send a header once per hop rather than as a single comma-separated value. Repeated `X-Forwarded-For` fields are combined in order (as the equivalent comma-separated list, [RFC 9110, 5.3](https://www.rfc-editor.org/rfc/rfc9110#section-5.3)), while for `X-Forwarded-Proto` the last field is used.
274+
273275
For more information, check [`ProxyHeadersMiddleware`](https://github.com/Kludex/uvicorn/blob/main/uvicorn/middleware/proxy_headers.py).
274276

275277
### Client Port

tests/middleware/test_proxy_headers.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,7 @@ async def _noop_send(message: ASGISendEvent) -> None: # pragma: no cover
582582

583583

584584
@pytest.mark.anyio
585-
async def test_proxy_headers_duplicate_x_forwarded_for_is_ignored() -> None:
585+
async def test_proxy_headers_duplicate_x_forwarded_for_is_combined() -> None:
586586
captured: dict[str, tuple[str, int] | None] = {}
587587

588588
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
@@ -592,29 +592,57 @@ async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
592592
middleware = ProxyHeadersMiddleware(app, trusted_hosts="127.0.0.1")
593593
scope = _make_http_scope(
594594
[
595-
(b"x-forwarded-for", b"198.51.100.23, 127.0.0.1"),
596-
(b"x-forwarded-for", b"203.0.113.66"),
595+
(b"x-forwarded-for", b"203.0.113.66, 198.51.100.23"),
596+
(b"x-forwarded-for", b"127.0.0.1"),
597597
]
598598
)
599599
await middleware(scope, _noop_receive, _noop_send)
600-
assert captured["client"] == ("127.0.0.1", 12345)
600+
assert captured["client"] == ("198.51.100.23", 0)
601601

602602

603603
@pytest.mark.anyio
604-
async def test_proxy_headers_duplicate_x_forwarded_proto_is_ignored() -> None:
604+
@pytest.mark.parametrize(
605+
("proto_headers", "expected"),
606+
[
607+
([(b"x-forwarded-proto", b"https"), (b"x-forwarded-proto", b"http")], "http"),
608+
([(b"x-forwarded-proto", b"http"), (b"x-forwarded-proto", b"https")], "https"),
609+
],
610+
)
611+
async def test_proxy_headers_duplicate_x_forwarded_proto_uses_last(
612+
proto_headers: list[tuple[bytes, bytes]],
613+
expected: str,
614+
) -> None:
605615
captured: dict[str, str] = {}
606616

607617
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
608618
assert scope["type"] == "http"
609619
captured["scheme"] = scope["scheme"]
610620

611621
middleware = ProxyHeadersMiddleware(app, trusted_hosts="127.0.0.1")
622+
scope = _make_http_scope(proto_headers, scheme="http")
623+
await middleware(scope, _noop_receive, _noop_send)
624+
assert captured["scheme"] == expected
625+
626+
627+
@pytest.mark.anyio
628+
async def test_proxy_headers_haproxy_behind_alb() -> None:
629+
captured: dict[str, object] = {}
630+
631+
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
632+
assert scope["type"] == "http"
633+
captured["client"] = scope["client"]
634+
captured["scheme"] = scope["scheme"]
635+
636+
middleware = ProxyHeadersMiddleware(app, trusted_hosts=["127.0.0.1", "10.0.0.1"])
612637
scope = _make_http_scope(
613638
[
614-
(b"x-forwarded-proto", b"http"),
639+
(b"x-forwarded-for", b"1.2.3.4"),
640+
(b"x-forwarded-for", b"10.0.0.1"),
641+
(b"x-forwarded-proto", b"https"),
615642
(b"x-forwarded-proto", b"https"),
616643
],
617644
scheme="http",
618645
)
619646
await middleware(scope, _noop_receive, _noop_send)
620-
assert captured["scheme"] == "http"
647+
assert captured["client"] == ("1.2.3.4", 0)
648+
assert captured["scheme"] == "https"

uvicorn/middleware/proxy_headers.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,26 +32,25 @@ async def __call__(self, scope: Scope, receive: ASGIReceiveCallable, send: ASGIS
3232
client_host = client_addr[0] if client_addr else None
3333

3434
if client_host in self.trusted_hosts:
35-
x_forwarded_proto_values: list[bytes] = []
35+
x_forwarded_proto_value: bytes | None = None
3636
x_forwarded_for_values: list[bytes] = []
3737
for name, value in scope["headers"]:
3838
if name == b"x-forwarded-proto":
39-
x_forwarded_proto_values.append(value)
39+
x_forwarded_proto_value = value
4040
elif name == b"x-forwarded-for":
4141
x_forwarded_for_values.append(value)
4242

43-
# Only consume the header when exactly one copy is present to avoid spoofing issues.
44-
if len(x_forwarded_proto_values) == 1:
45-
x_forwarded_proto = x_forwarded_proto_values[0].decode("latin1").strip()
43+
if x_forwarded_proto_value is not None:
44+
x_forwarded_proto = x_forwarded_proto_value.decode("latin1").strip()
4645

4746
if x_forwarded_proto in {"http", "https", "ws", "wss"}:
4847
if scope["type"] == "websocket":
4948
scope["scheme"] = x_forwarded_proto.replace("http", "ws")
5049
else:
5150
scope["scheme"] = x_forwarded_proto
5251

53-
if len(x_forwarded_for_values) == 1:
54-
x_forwarded_for = x_forwarded_for_values[0].decode("latin1")
52+
if x_forwarded_for_values:
53+
x_forwarded_for = b", ".join(x_forwarded_for_values).decode("latin1")
5554
host, port = self.trusted_hosts.get_trusted_client_address(x_forwarded_for)
5655

5756
if host:

0 commit comments

Comments
 (0)