Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/deployment/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,9 @@ Uvicorn currently supports the following headers:

- `X-Forwarded-For` ([MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For))
- `X-Forwarded-Proto`([MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto))
- `X-Forwarded-Host` ([MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host))

Uvicorn can use these headers to correctly set the client and protocol in the request.
Uvicorn can use these headers to correctly set the client, protocol, and host (including the `Host` request header and `scope["server"]`) in the request.
However as anyone can set these headers you must configure which "clients" you will trust to have set them correctly.

Uvicorn can be configured to trust IP Addresses (e.g. `127.0.0.1`), IP Networks (e.g. `10.100.0.0/16`),
Expand Down
2 changes: 1 addition & 1 deletion docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Note that WSGI mode always disables WebSocket support, as it is not supported by
## HTTP

* `--root-path <str>` - Set the ASGI `root_path` for applications submounted below a given URL path. **Default:** *""*.
* `--proxy-headers / --no-proxy-headers` - Enable/Disable X-Forwarded-Proto, X-Forwarded-For to populate remote address info. Defaults to enabled, but is restricted to only trusting connecting IPs in the `forwarded-allow-ips` configuration.
* `--proxy-headers / --no-proxy-headers` - Enable/Disable X-Forwarded-Proto, X-Forwarded-For, X-Forwarded-Host to populate remote address info. Defaults to enabled, but is restricted to only trusting connecting IPs in the `forwarded-allow-ips` configuration.
* `--forwarded-allow-ips <comma-separated-list>` - Comma separated list of IP Addresses, IP Networks, or literals (e.g. UNIX Socket path) to trust with proxy headers. Defaults to the `$FORWARDED_ALLOW_IPS` environment variable if available, or '127.0.0.1'. The literal `'*'` means trust everything.
* `--server-header / --no-server-header` - Enable/Disable default `Server` header. **Default:** *True*.
* `--date-header / --no-date-header` - Enable/Disable default `Date` header. **Default:** *True*.
Expand Down
128 changes: 128 additions & 0 deletions tests/middleware/test_proxy_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

X_FORWARDED_FOR = "X-Forwarded-For"
X_FORWARDED_PROTO = "X-Forwarded-Proto"
X_FORWARDED_HOST = "X-Forwarded-Host"


async def default_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
Expand Down Expand Up @@ -554,3 +555,130 @@ async def test_proxy_headers_empty_x_forwarded_for() -> None:
response = await client.get("/", headers=headers)
assert response.status_code == 200
assert response.text == "https://127.0.0.1:123"


async def host_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
"""Echoes the `host` header and `scope["server"]` so tests can assert both."""
headers = dict(scope["headers"]) # type: ignore[typeddict-item]
host_header = headers.get(b"host", b"").decode("latin1")
server = scope.get("server") # type: ignore[union-attr]
if server is not None:
server_repr = f"{server[0]}:{server[1]}"
else:
server_repr = "NONE" # pragma: no cover
response = Response(f"host={host_header} server={server_repr}", media_type="text/plain")
await response(scope, receive, send)


def make_host_client(
trusted_hosts: str | list[str],
client: tuple[str, int] = ("127.0.0.1", 123),
) -> httpx.AsyncClient:
app = ProxyHeadersMiddleware(host_app, trusted_hosts)
transport = httpx.ASGITransport(app=app, client=client) # type: ignore
return httpx.AsyncClient(transport=transport, base_url="http://testserver")


@pytest.mark.anyio
async def test_proxy_headers_x_forwarded_host_untrusted() -> None:
"""X-Forwarded-Host from an untrusted peer must be ignored."""
async with make_host_client("192.168.0.1") as client:
headers = {X_FORWARDED_HOST: "malicious.example"}
response = await client.get("/", headers=headers)
assert response.status_code == 200
assert "malicious.example" not in response.text


@pytest.mark.anyio
async def test_proxy_headers_empty_x_forwarded_host() -> None:
"""Empty X-Forwarded-Host leaves the original Host header and server untouched."""
async with make_host_client("*") as client:
response = await client.get("/", headers={X_FORWARDED_HOST: " "})
assert response.status_code == 200
assert response.text == "host=testserver server=testserver:None"


@pytest.mark.anyio
@pytest.mark.parametrize(
("forwarded_host", "expected_host", "expected_server"),
[
# Hostname without port -> defaults to scheme port (http -> 80)
("example.com", "example.com", "example.com:80"),
# Hostname with port
("example.com:8080", "example.com:8080", "example.com:8080"),
# IPv4 without port
("192.0.2.10", "192.0.2.10", "192.0.2.10:80"),
# IPv4 with port
("192.0.2.10:8080", "192.0.2.10:8080", "192.0.2.10:8080"),
# Bracketed IPv6 without port
("[2001:db8::1]", "[2001:db8::1]", "2001:db8::1:80"),
# Bracketed IPv6 with port
("[2001:db8::1]:8443", "[2001:db8::1]:8443", "2001:db8::1:8443"),
],
)
async def test_proxy_headers_x_forwarded_host(forwarded_host: str, expected_host: str, expected_server: str) -> None:
async with make_host_client("*") as client:
response = await client.get("/", headers={X_FORWARDED_HOST: forwarded_host})
assert response.status_code == 200
assert response.text == f"host={expected_host} server={expected_server}"


@pytest.mark.anyio
@pytest.mark.parametrize(
("scheme", "expected_port"),
[
("http", 80),
("https", 443),
("ws", 80),
("wss", 443),
],
)
async def test_proxy_headers_x_forwarded_host_default_port_follows_scheme(scheme: str, expected_port: int) -> None:
"""Without an explicit port, the default scope server port follows X-Forwarded-Proto."""
async with make_host_client("*") as client:
headers = {X_FORWARDED_HOST: "example.com", X_FORWARDED_PROTO: scheme}
response = await client.get("/", headers=headers)
assert response.status_code == 200
assert response.text == f"host=example.com server=example.com:{expected_port}"


@pytest.mark.anyio
async def test_proxy_headers_x_forwarded_host_replaces_original_host_header() -> None:
"""The forwarded host fully replaces the inbound Host header (no duplicates)."""
async with make_host_client("*") as client:
headers = {"Host": "internal.lan", X_FORWARDED_HOST: "public.example:9000"}
response = await client.get("/", headers=headers)
assert response.status_code == 200
assert response.text == "host=public.example:9000 server=public.example:9000"
assert response.text.count("host=") == 1


@pytest.mark.anyio
async def test_proxy_headers_combined_for_proto_host() -> None:
"""All three X-Forwarded-* headers compose: client, scheme, server, host all rewritten."""

async def echo_all(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
headers = dict(scope["headers"]) # type: ignore[typeddict-item]
host_header = headers.get(b"host", b"").decode("latin1")
server = scope.get("server") # type: ignore[union-attr]
client = scope.get("client") # type: ignore[union-attr]
body = (
f"scheme={scope['scheme']} " # type: ignore[typeddict-item]
f"client={client[0]}:{client[1]} "
f"server={server[0]}:{server[1]} "
f"host={host_header}"
)
await Response(body, media_type="text/plain")(scope, receive, send)

middleware = ProxyHeadersMiddleware(echo_all, trusted_hosts="*")
transport = httpx.ASGITransport(app=middleware, client=("127.0.0.1", 123)) # type: ignore
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
headers = {
X_FORWARDED_FOR: "1.2.3.4",
X_FORWARDED_PROTO: "https",
X_FORWARDED_HOST: "public.example:9000",
}
response = await client.get("/", headers=headers)

assert response.status_code == 200
assert response.text == "scheme=https client=1.2.3.4:0 server=public.example:9000 host=public.example:9000"
21 changes: 17 additions & 4 deletions uvicorn/middleware/proxy_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ class ProxyHeadersMiddleware:
"""Middleware for handling known proxy headers

This middleware can be used when a known proxy is fronting the application,
and is trusted to be properly setting the `X-Forwarded-Proto` and
`X-Forwarded-For` headers with the connecting client information.
and is trusted to be properly setting the `X-Forwarded-Proto`, `X-Forwarded-For`
and `X-Forwarded-Host` headers with the connecting client information.

Modifies the `client` and `scheme` information so that they reference
the connecting client, rather that the connecting proxy.
Modifies the `client`, `scheme` and `server` information, plus the `host` header,
so that they reference the connecting client rather than the connecting proxy.

References:
- <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers#Proxies>
- <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For>
- <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host>
"""

def __init__(self, app: ASGI3Application, trusted_hosts: list[str] | str = "127.0.0.1") -> None:
Expand Down Expand Up @@ -53,6 +54,18 @@ async def __call__(self, scope: Scope, receive: ASGIReceiveCallable, send: ASGIS
# See: https://github.com/Kludex/uvicorn/issues/1068
scope["client"] = (host, port)

if b"x-forwarded-host" in headers:
x_forwarded_host = headers[b"x-forwarded-host"].decode("latin1").strip()

if x_forwarded_host:
host, port = _parse_host_port(x_forwarded_host)
if not port:
Comment on lines +58 to +62
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse only one X-Forwarded-Host hop before rewriting host

When a trusted proxy sends a multi-hop X-Forwarded-Host value (comma-separated, which is common with chained proxies), this code parses the entire header as one host instead of selecting a single hop first. That means scope["server"] can be set to an invalid hostname like "example.com,proxy.local" (or lose a valid port in IPv6+port chains), and the rewritten Host header will also contain the full list, which can break URL generation/redirect behavior in proxied production setups. Split the header into hops (and choose the intended trusted hop) before calling _parse_host_port.

Useful? React with 👍 / 👎.

port = 443 if scope.get("scheme") in ("https", "wss") else 80
scope["server"] = (host, port)
scope["headers"] = [(name, value) for name, value in scope["headers"] if name != b"host"] + [
(b"host", x_forwarded_host.encode("latin1"))
]

return await self.app(scope, receive, send)


Expand Down
Loading