Skip to content

Commit

Permalink
Create setting/flag forwarded-port to enable/disable X-Forwarded-Port…
Browse files Browse the repository at this point in the history
… to populate remote address info (closes encode#1974)
  • Loading branch information
victor-torres committed Sep 30, 2024
1 parent c7668ce commit f439d0e
Show file tree
Hide file tree
Showing 7 changed files with 46 additions and 5 deletions.
2 changes: 2 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ 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 handling.
--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
Expand Down
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ 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 handling.
--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
Expand Down
1 change: 1 addition & 0 deletions docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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> 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.

Expand Down
22 changes: 21 additions & 1 deletion tests/middleware/test_proxy_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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")

Expand Down Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion uvicorn/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -331,8 +332,10 @@ def __init__(
self.forwarded_allow_ips: list[str] | str
if forwarded_allow_ips is None:
self.forwarded_allow_ips = os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1")
self.forwarded_port = False
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.')
Expand Down Expand Up @@ -467,7 +470,7 @@ 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

Expand Down
6 changes: 6 additions & 0 deletions uvicorn/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=True,
help="Enable/Disable X-Forwarded-Port to " "populate remote address info.",
)
@click.option(
"--root-path",
type=str,
Expand Down
13 changes: 10 additions & 3 deletions uvicorn/middleware/proxy_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ class ProxyHeadersMiddleware:
- <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For>
"""

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":
Expand Down Expand Up @@ -53,8 +55,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)
Expand Down

0 comments on commit f439d0e

Please sign in to comment.