diff --git a/docs/deployment/index.md b/docs/deployment/index.md index c9c0d64ce..c16fb5db5 100644 --- a/docs/deployment/index.md +++ b/docs/deployment/index.md @@ -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. diff --git a/docs/settings.md b/docs/settings.md index a36b83672..7da362d6e 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -115,7 +115,7 @@ Note that WSGI mode always disables WebSocket support, as it is not supported by * `--root-path ` - 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 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 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 ` - Specify custom default HTTP response headers as a Name:Value pair. May be used multiple times. diff --git a/tests/middleware/test_proxy_headers.py b/tests/middleware/test_proxy_headers.py index 24a3feb51..9a2fa0949 100644 --- a/tests/middleware/test_proxy_headers.py +++ b/tests/middleware/test_proxy_headers.py @@ -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") @@ -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", diff --git a/tests/protocols/test_utils.py b/tests/protocols/test_utils.py index 3be0c6e39..8fffba572 100644 --- a/tests/protocols/test_utils.py +++ b/tests/protocols/test_utils.py @@ -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(): @@ -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) diff --git a/uvicorn/main.py b/uvicorn/main.py index e0e1b0a6a..c114087d6 100644 --- a/uvicorn/main.py +++ b/uvicorn/main.py @@ -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( diff --git a/uvicorn/middleware/proxy_headers.py b/uvicorn/middleware/proxy_headers.py index b9ee14a6c..feb6b810a 100644 --- a/uvicorn/middleware/proxy_headers.py +++ b/uvicorn/middleware/proxy_headers.py @@ -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"]) diff --git a/uvicorn/protocols/http/h11_impl.py b/uvicorn/protocols/http/h11_impl.py index b8cdde3ab..bfc45e9a6 100644 --- a/uvicorn/protocols/http/h11_impl.py +++ b/uvicorn/protocols/http/h11_impl.py @@ -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 diff --git a/uvicorn/protocols/http/httptools_impl.py b/uvicorn/protocols/http/httptools_impl.py index e8795ed35..37972ab26 100644 --- a/uvicorn/protocols/http/httptools_impl.py +++ b/uvicorn/protocols/http/httptools_impl.py @@ -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() diff --git a/uvicorn/protocols/utils.py b/uvicorn/protocols/utils.py index e1d6f01d5..cb5894e20 100644 --- a/uvicorn/protocols/utils.py +++ b/uvicorn/protocols/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import os import urllib.parse from uvicorn._types import WWWScope @@ -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: diff --git a/uvicorn/protocols/websockets/websockets_impl.py b/uvicorn/protocols/websockets/websockets_impl.py index 1e895cd49..887ca217a 100644 --- a/uvicorn/protocols/websockets/websockets_impl.py +++ b/uvicorn/protocols/websockets/websockets_impl.py @@ -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] diff --git a/uvicorn/protocols/websockets/websockets_sansio_impl.py b/uvicorn/protocols/websockets/websockets_sansio_impl.py index abd4c2b30..2ba6a574a 100644 --- a/uvicorn/protocols/websockets/websockets_sansio_impl.py +++ b/uvicorn/protocols/websockets/websockets_sansio_impl.py @@ -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] diff --git a/uvicorn/protocols/websockets/wsproto_impl.py b/uvicorn/protocols/websockets/wsproto_impl.py index 14f501108..ecba6a353 100644 --- a/uvicorn/protocols/websockets/wsproto_impl.py +++ b/uvicorn/protocols/websockets/wsproto_impl.py @@ -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]