Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create setting/flag to enable/disable X-Forwarded-Port #2473

Closed
wants to merge 7 commits into from
Closed
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
3 changes: 3 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
7 changes: 6 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 @@ -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.')

Expand Down Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion uvicorn/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 11 additions & 3 deletions uvicorn/middleware/proxy_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ 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 +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)
Expand Down
Loading