Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion docs/deployment/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ Uvicorn can use these headers to correctly set the client and protocol in the re
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`),
or Literals (e.g. `/path/to/socket.sock`). When running from CLI these are configured using `--forwarded-allow-ips`.
or Unix sockets (`unix:`). When running from CLI these are configured using `--forwarded-allow-ips`.

!!! Warning "Only trust clients you can actually trust!"
Incorrectly trusting other clients can lead to malicious actors spoofing their apparent client address to your application.
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

* `--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.
* `--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.
* `--forwarded-allow-ips <comma-separated-list>` - Comma separated list of IP Addresses, IP Networks, or literals to trust with proxy headers. The literal `'unix:'` represents a Unix domain socket. 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*.
* `--header <name:value>` - Specify custom default HTTP response headers as a Name:Value pair. May be used multiple times.
Expand Down
55 changes: 52 additions & 3 deletions tests/middleware/test_proxy_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,30 @@ async def default_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISend

def make_httpx_client(
trusted_hosts: str | list[str],
client: tuple[str, int] = ("127.0.0.1", 123),
client: tuple[str, int] | None = ("127.0.0.1", 123),
server: tuple[str, int | None] | None = ("testserver", 80),
) -> httpx.AsyncClient:
"""Create async client for use in test cases.

Args:
trusted_hosts: trusted_hosts for proxy middleware
client: transport client to use
client: value of scope["client"] as seen by middleware
server: value of scope["server"] as seen by middleware
"""

app = ProxyHeadersMiddleware(default_app, trusted_hosts)
transport = httpx.ASGITransport(app=app, client=client) # type: ignore

async def wrapper(
scope: Scope,
receive: ASGIReceiveCallable,
send: ASGISendCallable,
) -> None:
if scope["type"] != "lifespan":
scope["client"] = client
scope["server"] = server
return await app(scope, receive, send)

transport = httpx.ASGITransport(app=wrapper) # type: ignore
return httpx.AsyncClient(transport=transport, base_url="http://testserver")


Expand Down Expand Up @@ -441,6 +454,42 @@ async def test_proxy_headers_invalid_x_forwarded_for() -> None:
assert response.text == "https://1.2.3.4:0"


@pytest.mark.anyio
@pytest.mark.parametrize(
("trusted_hosts", "forwarded_for", "expected"),
[
("*", "1.2.3.4, 10.0.2.1", "https://1.2.3.4:0"),
("127.0.0.1", "1.2.3.4, 10.0.2.1", "http://NONE"),
("unix:", "1.2.3.4, 10.0.2.1", "https://10.0.2.1:0"),
("unix:", "1.2.3.4, 10.0.2.1, unix:", "https://10.0.2.1:0"),
(["unix:", "192.168.0.2"], "1.2.3.4, 10.0.2.1, 192.168.0.2", "https://10.0.2.1:0"),
],
)
async def test_proxy_headers_unix_socket(trusted_hosts, forwarded_for, expected) -> None:
async with make_httpx_client(trusted_hosts, client=None, server=("/xsock", None)) as client:
headers = {X_FORWARDED_FOR: forwarded_for, X_FORWARDED_PROTO: "https"}
response = await client.get("/", headers=headers)
assert response.status_code == 200
assert response.text == expected


@pytest.mark.anyio
@pytest.mark.parametrize(
("trusted_hosts", "forwarded_for", "expected"),
[
("*", "1.2.3.4, 10.0.2.1", "https://1.2.3.4:0"),
("10.0.2.1", "1.2.3.4, 10.0.2.1", "http://NONE"),
("unix:", "1.2.3.4, 10.0.2.1", "http://NONE"),
],
)
async def test_proxy_headers_unknown_socket(trusted_hosts, forwarded_for, expected) -> None:
async with make_httpx_client(trusted_hosts, client=None, server=None) as client:
headers = {X_FORWARDED_FOR: forwarded_for, X_FORWARDED_PROTO: "https"}
response = await client.get("/", headers=headers)
assert response.status_code == 200
assert response.text == expected


@pytest.mark.anyio
@pytest.mark.parametrize(
"forwarded_proto,expected",
Expand Down
16 changes: 11 additions & 5 deletions tests/protocols/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ def test_get_local_addr_with_socket():
assert get_local_addr(transport) == ("123.45.6.7", 123)

if hasattr(socket, "AF_UNIX"): # pragma: no cover
transport = MockTransport({"socket": MockSocket(family=socket.AF_UNIX, sockname=("127.0.0.1", 8000))})
assert get_local_addr(transport) == ("127.0.0.1", 8000)
transport = MockTransport({"socket": MockSocket(family=socket.AF_UNIX, sockname="path/to/unix-domain-socket")})
assert get_local_addr(transport) == ("path/to/unix-domain-socket", None)

transport = MockTransport({"socket": MockSocket(family=socket.AF_UNIX, sockname=b"\0abstract-socket")})
assert get_local_addr(transport) == ("\0abstract-socket", None)


def test_get_remote_addr_with_socket():
Expand All @@ -56,13 +59,16 @@ def test_get_remote_addr_with_socket():
assert get_remote_addr(transport) == ("123.45.6.7", 123)

if hasattr(socket, "AF_UNIX"): # pragma: no cover
transport = MockTransport({"socket": MockSocket(family=socket.AF_UNIX, peername=("127.0.0.1", 8000))})
assert get_remote_addr(transport) == ("127.0.0.1", 8000)
transport = MockTransport({"socket": MockSocket(family=socket.AF_UNIX, peername="")})
assert get_remote_addr(transport) is None


def test_get_local_addr():
transport = MockTransport({"sockname": "path/to/unix-domain-socket"})
assert get_local_addr(transport) is None
assert get_local_addr(transport) == ("path/to/unix-domain-socket", None)

transport = MockTransport({"sockname": b"\0abstract-socket"})
assert get_local_addr(transport) == ("\0abstract-socket", None)

transport = MockTransport({"sockname": ("123.45.6.7", 123)})
assert get_local_addr(transport) == ("123.45.6.7", 123)
Expand Down
6 changes: 3 additions & 3 deletions uvicorn/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,9 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
"--forwarded-allow-ips",
type=str,
default=None,
help="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'. "
help="Comma separated list of IP Addresses, IP Networks, or literals to trust with proxy headers. "
"The literal 'unix:' represents a Unix domain socket. "
"Defaults to the $FORWARDED_ALLOW_IPS environment variable if available, or '127.0.0.1'. "
"The literal '*' means trust everything.",
)
@click.option(
Expand Down
8 changes: 7 additions & 1 deletion uvicorn/middleware/proxy_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ async def __call__(self, scope: Scope, receive: ASGIReceiveCallable, send: ASGIS
return await self.app(scope, receive, send)

client_addr = scope.get("client")
client_host = client_addr[0] if client_addr else None
server_addr = scope.get("server")
if client_addr:
client_host = client_addr[0]
elif server_addr and server_addr[1] is None:
client_host = "unix:"
else:
client_host = None

if client_host in self.trusted_hosts:
headers = dict(scope["headers"])
Expand Down
2 changes: 1 addition & 1 deletion uvicorn/protocols/http/h11_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def __init__(
# Per-connection state
self.transport: asyncio.Transport = None # type: ignore[assignment]
self.flow: FlowControl = None # type: ignore[assignment]
self.server: tuple[str, int] | None = None
self.server: tuple[str, int | None] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["http", "https"] | None = None

Expand Down
2 changes: 1 addition & 1 deletion uvicorn/protocols/http/httptools_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def __init__(
# Per-connection state
self.transport: asyncio.Transport = None # type: ignore[assignment]
self.flow: FlowControl = None # type: ignore[assignment]
self.server: tuple[str, int] | None = None
self.server: tuple[str, int | None] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["http", "https"] | None = None
self.pipeline: deque[tuple[RequestResponseCycle, ASGI3Application]] = deque()
Expand Down
14 changes: 9 additions & 5 deletions uvicorn/protocols/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import os
import urllib.parse

from uvicorn._types import WWWScope
Expand All @@ -26,16 +27,19 @@ def get_remote_addr(transport: asyncio.Transport) -> tuple[str, int] | None:
return None


def get_local_addr(transport: asyncio.Transport) -> tuple[str, int] | None:
def get_local_addr(transport: asyncio.Transport) -> tuple[str, int | None] | None:
socket_info = transport.get_extra_info("socket")
if socket_info is not None:
info = socket_info.getsockname()
else:
info = transport.get_extra_info("sockname")

return (str(info[0]), int(info[1])) if isinstance(info, tuple) else None
info = transport.get_extra_info("sockname")
if info is not None and isinstance(info, (list, tuple)) and len(info) == 2:
if isinstance(info, (list, tuple)):
return (str(info[0]), int(info[1]))
return None
elif isinstance(info, (bytes, str)):
return (os.fsdecode(info), None)
else:
return None


def is_ssl(transport: asyncio.Transport) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion uvicorn/protocols/websockets/websockets_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def __init__(

# Connection state
self.transport: asyncio.Transport = None # type: ignore[assignment]
self.server: tuple[str, int] | None = None
self.server: tuple[str, int | None] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["wss", "ws"] = None # type: ignore[assignment]

Expand Down
2 changes: 1 addition & 1 deletion uvicorn/protocols/websockets/websockets_sansio_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def __init__(

# Connection state
self.transport: asyncio.Transport = None # type: ignore[assignment]
self.server: tuple[str, int] | None = None
self.server: tuple[str, int | None] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["wss", "ws"] = None # type: ignore[assignment]

Expand Down
2 changes: 1 addition & 1 deletion uvicorn/protocols/websockets/wsproto_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def __init__(

# Connection state
self.transport: asyncio.Transport = None # type: ignore[assignment]
self.server: tuple[str, int] | None = None
self.server: tuple[str, int | None] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["wss", "ws"] = None # type: ignore[assignment]

Expand Down