Skip to content

Commit 4fc0372

Browse files
committed
Add ProxyFix middleware
This allows for Hypercorn to be used behind a proxy with the headers being "fixed" such that the proxy is not present as far as the app is concerned. This makes it easier to write applications that run behind proxies. Note I've defaulted to legacy mode as AWS's load balancers don't support the modern Forwarded header and I assume that makes up a large percentage of real world usage.
1 parent 2d2c62b commit 4fc0372

File tree

5 files changed

+178
-0
lines changed

5 files changed

+178
-0
lines changed

docs/how_to_guides/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ How to guides
1111
dispatch_apps.rst
1212
http_https_redirect.rst
1313
logging.rst
14+
proxy_fix.rst
1415
server_names.rst
1516
statsd.rst
1617
wsgi_apps.rst

docs/how_to_guides/proxy_fix.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
Fixing proxy headers
2+
====================
3+
4+
If you are serving Hypercorn behind a proxy e.g. a load balancer the
5+
client-address, scheme, and host-header will match that of the
6+
connection between the proxy and Hypercorn rather than the user-agent
7+
(client). However, most proxies provide headers with the original
8+
user-agent (client) values which can be used to "fix" the headers to
9+
these values.
10+
11+
Modern proxies should provide this information via a ``Forwarded``
12+
header from `RFC 7239
13+
<https://datatracker.ietf.org/doc/html/rfc7239>`_. However, this is
14+
rare in practice with legacy proxies using a combination of
15+
``X-Forwarded-For``, ``X-Forwarded-Proto`` and
16+
``X-Forwarded-Host``. It is important that you chose the correct mode
17+
(legacy, or modern) based on the proxy you use.
18+
19+
To use the proxy fix middleware behind a single legacy proxy simply
20+
wrap your app and serve the wrapped app,
21+
22+
.. code-block:: python
23+
24+
from hypercorn.middleware import ProxyFixMiddleware
25+
26+
fixed_app = ProxyFixMiddleware(app, mode="legacy", trusted_hops=1)
27+
28+
.. warning::
29+
30+
The mode and number of trusted hops must match your setup or the
31+
user-agent (client) may be trusted and hence able to set
32+
alternative for, proto, and host values. This can, depending on
33+
your usage in the app, lead to security vulnerabilities.

src/hypercorn/middleware/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
from .dispatcher import DispatcherMiddleware
44
from .http_to_https import HTTPToHTTPSRedirectMiddleware
5+
from .proxy_fix import ProxyFixMiddleware
56
from .wsgi import AsyncioWSGIMiddleware, TrioWSGIMiddleware
67

78
__all__ = (
89
"AsyncioWSGIMiddleware",
910
"DispatcherMiddleware",
1011
"HTTPToHTTPSRedirectMiddleware",
12+
"ProxyFixMiddleware",
1113
"TrioWSGIMiddleware",
1214
)

src/hypercorn/middleware/proxy_fix.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from __future__ import annotations
2+
3+
from copy import deepcopy
4+
from typing import Callable, Iterable, Literal, Optional, Tuple
5+
6+
from ..typing import ASGIFramework, Scope
7+
8+
9+
class ProxyFixMiddleware:
10+
def __init__(
11+
self,
12+
app: ASGIFramework,
13+
mode: Literal["legacy", "modern"] = "legacy",
14+
trusted_hops: int = 1,
15+
) -> None:
16+
self.app = app
17+
self.mode = mode
18+
self.trusted_hops = trusted_hops
19+
20+
async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> None:
21+
if scope["type"] in {"http", "websocket"}:
22+
scope = deepcopy(scope)
23+
headers = scope["headers"] # type: ignore
24+
client: Optional[str] = None
25+
scheme: Optional[str] = None
26+
host: Optional[str] = None
27+
28+
if (
29+
self.mode == "modern"
30+
and (value := _get_trusted_value(b"forwarded", headers, self.trusted_hops))
31+
is not None
32+
):
33+
for part in value.split(";"):
34+
if part.startswith("for="):
35+
client = part[4:].strip()
36+
elif part.startswith("host="):
37+
host = part[5:].strip()
38+
elif part.startswith("proto="):
39+
scheme = part[6:].strip()
40+
41+
else:
42+
client = _get_trusted_value(b"x-forwarded-for", headers, self.trusted_hops)
43+
scheme = _get_trusted_value(b"x-forwarded-proto", headers, self.trusted_hops)
44+
host = _get_trusted_value(b"x-forwarded-host", headers, self.trusted_hops)
45+
46+
if client is not None:
47+
scope["client"] = (client, 0) # type: ignore
48+
49+
if scheme is not None:
50+
scope["scheme"] = scheme # type: ignore
51+
52+
if host is not None:
53+
headers = [
54+
(name, header_value)
55+
for name, header_value in headers
56+
if name.lower() != b"host"
57+
]
58+
headers.append((b"host", host))
59+
scope["headers"] = headers # type: ignore
60+
61+
await self.app(scope, receive, send)
62+
63+
64+
def _get_trusted_value(
65+
name: bytes, headers: Iterable[Tuple[bytes, bytes]], trusted_hops: int
66+
) -> Optional[str]:
67+
if trusted_hops == 0:
68+
return None
69+
70+
values = []
71+
for header_name, header_value in headers:
72+
if header_name.lower() == name:
73+
values.extend([value.decode("latin1").strip() for value in header_value.split(b",")])
74+
75+
if len(values) >= trusted_hops:
76+
return values[-trusted_hops]
77+
78+
return None

tests/middleware/test_proxy_fix.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
from unittest.mock import AsyncMock
4+
5+
import pytest
6+
7+
from hypercorn.middleware import ProxyFixMiddleware
8+
from hypercorn.typing import HTTPScope
9+
10+
11+
@pytest.mark.asyncio
12+
async def test_proxy_fix_legacy() -> None:
13+
mock = AsyncMock()
14+
app = ProxyFixMiddleware(mock)
15+
scope: HTTPScope = {
16+
"type": "http",
17+
"asgi": {},
18+
"http_version": "2",
19+
"method": "GET",
20+
"scheme": "http",
21+
"path": "/",
22+
"raw_path": b"/",
23+
"query_string": b"",
24+
"root_path": "",
25+
"headers": [
26+
(b"x-forwarded-for", b"127.0.0.1"),
27+
(b"x-forwarded-for", b"127.0.0.2"),
28+
(b"x-forwarded-proto", b"http,https"),
29+
],
30+
"client": ("127.0.0.3", 80),
31+
"server": None,
32+
"extensions": {},
33+
}
34+
await app(scope, None, None)
35+
mock.assert_called()
36+
assert mock.call_args[0][0]["client"] == ("127.0.0.2", 0)
37+
assert mock.call_args[0][0]["scheme"] == "https"
38+
39+
40+
@pytest.mark.asyncio
41+
async def test_proxy_fix_modern() -> None:
42+
mock = AsyncMock()
43+
app = ProxyFixMiddleware(mock, mode="modern")
44+
scope: HTTPScope = {
45+
"type": "http",
46+
"asgi": {},
47+
"http_version": "2",
48+
"method": "GET",
49+
"scheme": "http",
50+
"path": "/",
51+
"raw_path": b"/",
52+
"query_string": b"",
53+
"root_path": "",
54+
"headers": [
55+
(b"forwarded", b"for=127.0.0.1;proto=http,for=127.0.0.2;proto=https"),
56+
],
57+
"client": ("127.0.0.3", 80),
58+
"server": None,
59+
"extensions": {},
60+
}
61+
await app(scope, None, None)
62+
mock.assert_called()
63+
assert mock.call_args[0][0]["client"] == ("127.0.0.2", 0)
64+
assert mock.call_args[0][0]["scheme"] == "https"

0 commit comments

Comments
 (0)