diff --git a/docs/deployment.md b/docs/deployment.md index e1854deff..51d001f5d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -99,6 +99,9 @@ Options: to the $FORWARDED_ALLOW_IPS environment variable if available, or '127.0.0.1'. The literal '*' means trust everything. + --forwarded-port / --no-forwarded-port + Enable/Disable X-Forwarded-Port to populate + remote address info. --root-path TEXT Set the ASGI 'root_path' for applications submounted below a given URL path. --limit-concurrency INTEGER Maximum number of concurrent connections or diff --git a/docs/index.md b/docs/index.md index 6ea346c0e..e4a383737 100644 --- a/docs/index.md +++ b/docs/index.md @@ -169,6 +169,9 @@ Options: to the $FORWARDED_ALLOW_IPS environment variable if available, or '127.0.0.1'. The literal '*' means trust everything. + --forwarded-port / --no-forwarded-port + Enable/Disable X-Forwarded-Port to populate + remote address info. --root-path TEXT Set the ASGI 'root_path' for applications submounted below a given URL path. --limit-concurrency INTEGER Maximum number of concurrent connections or diff --git a/docs/settings.md b/docs/settings.md index a4439c3d0..0db7dcb23 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -91,6 +91,7 @@ Note that WSGI mode always disables WebSocket support, as it is not supported by * `--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-port` / `--no-forwarded-port` - Enable/Disable X-Forwarded-Port to populate remote address info. Defaults to disabled. * `--server-header` / `--no-server-header` - Enable/Disable default `Server` header. * `--date-header` / `--no-date-header` - Enable/Disable default `Date` header. diff --git a/tests/middleware/test_proxy_headers.py b/tests/middleware/test_proxy_headers.py index a2cbde775..5af3a58fc 100644 --- a/tests/middleware/test_proxy_headers.py +++ b/tests/middleware/test_proxy_headers.py @@ -22,6 +22,7 @@ X_FORWARDED_FOR = "X-Forwarded-For" X_FORWARDED_PROTO = "X-Forwarded-Proto" +X_FORWARDED_PORT = "X-Forwarded-Port" async def default_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: @@ -39,6 +40,7 @@ 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), + forwarded_port: bool = False, ) -> httpx.AsyncClient: """Create async client for use in test cases. @@ -47,7 +49,7 @@ def make_httpx_client( client: transport client to use """ - app = ProxyHeadersMiddleware(default_app, trusted_hosts) + app = ProxyHeadersMiddleware(default_app, trusted_hosts, forwarded_port) transport = httpx.ASGITransport(app=app, client=client) # type: ignore return httpx.AsyncClient(transport=transport, base_url="http://testserver") @@ -422,6 +424,24 @@ async def test_proxy_headers_multiple_proxies(trusted_hosts: str | list[str], ex assert response.text == expected +@pytest.mark.anyio +@pytest.mark.parametrize( + ("forwarded_port", "port", "expected"), + [ + (True, "443", 443), + (True, "1234", 1234), + (False, "1234", 0), + (False, "443", 0), + ], +) +async def test_proxy_headers_with_forwarded_port(forwarded_port, port, expected) -> None: + async with make_httpx_client("*", forwarded_port=forwarded_port) as client: + headers = {X_FORWARDED_FOR: "192.168.0.2", X_FORWARDED_PROTO: "https", X_FORWARDED_PORT: port} + response = await client.get("/", headers=headers) + assert response.status_code == 200 + assert response.text == f"https://192.168.0.2:{expected}" + + @pytest.mark.anyio async def test_proxy_headers_invalid_x_forwarded_for() -> None: async with make_httpx_client("*") as client: diff --git a/uvicorn/config.py b/uvicorn/config.py index 9aff8c968..4de6abbcf 100644 --- a/uvicorn/config.py +++ b/uvicorn/config.py @@ -205,6 +205,7 @@ def __init__( server_header: bool = True, date_header: bool = True, forwarded_allow_ips: list[str] | str | None = None, + forwarded_port: bool = False, root_path: str = "", limit_concurrency: int | None = None, limit_max_requests: int | None = None, @@ -334,6 +335,8 @@ def __init__( else: self.forwarded_allow_ips = forwarded_allow_ips # pragma: full coverage + self.forwarded_port = forwarded_port + if self.reload and self.workers > 1: logger.warning('"workers" flag is ignored when reloading is enabled.') @@ -467,7 +470,9 @@ def load(self) -> None: if logger.getEffectiveLevel() <= TRACE_LOG_LEVEL: self.loaded_app = MessageLoggerMiddleware(self.loaded_app) if self.proxy_headers: - self.loaded_app = ProxyHeadersMiddleware(self.loaded_app, trusted_hosts=self.forwarded_allow_ips) + self.loaded_app = ProxyHeadersMiddleware( + self.loaded_app, trusted_hosts=self.forwarded_allow_ips, forwarded_port=self.forwarded_port + ) self.loaded = True diff --git a/uvicorn/main.py b/uvicorn/main.py index 43956622d..d134f7288 100644 --- a/uvicorn/main.py +++ b/uvicorn/main.py @@ -222,7 +222,7 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No "--proxy-headers/--no-proxy-headers", is_flag=True, default=True, - help="Enable/Disable X-Forwarded-Proto, X-Forwarded-For, X-Forwarded-Port to " "populate remote address info.", + help="Enable/Disable X-Forwarded-Proto, X-Forwarded-For, X-Forwarded-Port to populate remote address info.", ) @click.option( "--server-header/--no-server-header", @@ -245,6 +245,12 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No "$FORWARDED_ALLOW_IPS environment variable if available, or '127.0.0.1'. " "The literal '*' means trust everything.", ) +@click.option( + "--forwarded-port/--no-forwarded-port", + is_flag=True, + default=False, + help="Enable/Disable X-Forwarded-Port to populate remote address info.", +) @click.option( "--root-path", type=str, @@ -390,6 +396,7 @@ def main( server_header: bool, date_header: bool, forwarded_allow_ips: str, + forwarded_port: bool, root_path: str, limit_concurrency: int, backlog: int, @@ -439,6 +446,7 @@ def main( server_header=server_header, date_header=date_header, forwarded_allow_ips=forwarded_allow_ips, + forwarded_port=forwarded_port, root_path=root_path, limit_concurrency=limit_concurrency, backlog=backlog, @@ -491,6 +499,7 @@ def run( server_header: bool = True, date_header: bool = True, forwarded_allow_ips: list[str] | str | None = None, + forwarded_port: bool = False, root_path: str = "", limit_concurrency: int | None = None, backlog: int = 2048, @@ -543,6 +552,7 @@ def run( server_header=server_header, date_header=date_header, forwarded_allow_ips=forwarded_allow_ips, + forwarded_port=forwarded_port, root_path=root_path, limit_concurrency=limit_concurrency, backlog=backlog, diff --git a/uvicorn/middleware/proxy_headers.py b/uvicorn/middleware/proxy_headers.py index ce4fd8c01..84b2f56ae 100644 --- a/uvicorn/middleware/proxy_headers.py +++ b/uvicorn/middleware/proxy_headers.py @@ -20,9 +20,12 @@ class ProxyHeadersMiddleware: - """ - def __init__(self, app: ASGI3Application, trusted_hosts: list[str] | str = "127.0.0.1") -> None: + def __init__( + self, app: ASGI3Application, trusted_hosts: list[str] | str = "127.0.0.1", forwarded_port: bool = False + ) -> None: self.app = app self.trusted_hosts = _TrustedHosts(trusted_hosts) + self.forwarded_port = forwarded_port async def __call__(self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: if scope["type"] == "lifespan": @@ -53,8 +56,13 @@ async def __call__(self, scope: Scope, receive: ASGIReceiveCallable, send: ASGIS # See: https://github.com/encode/uvicorn/issues/1068 # We've lost the connecting client's port information by now, - # so only include the host. - port = 0 + # so unless X-Forwarded-Port is available and --forwarded-port is enabled, + # only include the host. + if self.forwarded_port and b"x-forwarded-port" in headers: + x_forwarded_port = headers[b"x-forwarded-port"].decode("latin1") + port = int(x_forwarded_port) + else: + port = 0 scope["client"] = (host, port) return await self.app(scope, receive, send)