diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 59668b0..97315bf 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -56,6 +56,10 @@ jobs: run: | .venv/bin/python -m mypy -p kaya.cors .venv/bin/python -m unittest discover -s packages/kaya-cors/tests + - name: Check kaya-forwarded + run: | + .venv/bin/python -m mypy -p kaya.forwarded + .venv/bin/python -m unittest discover -s packages/kaya-forwarded/tests - name: Publish kaya-core artifacts env: TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }} @@ -120,3 +124,11 @@ jobs: run: | .venv/bin/pyproject-build packages/kaya-cors .venv/bin/twine upload --repository gitea packages/kaya-cors/dist/*.whl packages/kaya-cors/dist/*.tar.gz + - name: Publish kaya-forwarded artifacts + env: + TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }} + TWINE_USERNAME: ${{ vars.PUBLISHER_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PUBLISHER_TOKEN }} + run: | + .venv/bin/pyproject-build packages/kaya-forwarded + .venv/bin/twine upload --repository gitea packages/kaya-forwarded/dist/*.whl packages/kaya-forwarded/dist/*.tar.gz diff --git a/README.md b/README.md index ab1ad71..e11608c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This repository is a monorepo for the Kaya framework. The code is split into ind - **kaya-oidc** — OpenID Connect authentication (`packages/kaya-oidc/`) - **kaya-openapi** — automatic OpenAPI specification generation (`packages/kaya-openapi/`) - **kaya-cors** — CORS (Cross-Origin Resource Sharing) support (`packages/kaya-cors/`) +- **kaya-forwarded** — trusted-proxy `Forwarded`/`X-Forwarded-*` client address resolution (`packages/kaya-forwarded/`) Additional `kaya-*` packages can be added as new directories under `packages/`. @@ -28,7 +29,7 @@ pip install --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/si Install the packages in development mode: ```bash -pip install -e packages/kaya-core -e packages/kaya-rsgi -e packages/kaya-session -e packages/kaya-session-redis -e packages/kaya-session-memcache -e packages/kaya-oidc -e packages/kaya-openapi -e packages/kaya-cors +pip install -e packages/kaya-core -e packages/kaya-rsgi -e packages/kaya-session -e packages/kaya-session-redis -e packages/kaya-session-memcache -e packages/kaya-oidc -e packages/kaya-openapi -e packages/kaya-cors -e packages/kaya-forwarded ``` Run the example: @@ -48,6 +49,7 @@ python -m unittest discover -s packages/kaya-session-memcache/tests python -m unittest discover -s packages/kaya-oidc/tests python -m unittest discover -s packages/kaya-openapi/tests python -m unittest discover -s packages/kaya-cors/tests +python -m unittest discover -s packages/kaya-forwarded/tests ``` ## Static analysis @@ -61,6 +63,7 @@ mypy -p kaya.session.memcache mypy -p kaya.oidc mypy -p kaya.openapi mypy -p kaya.cors +mypy -p kaya.forwarded ``` ## Building packages @@ -74,4 +77,5 @@ python -m build packages/kaya-session-memcache python -m build packages/kaya-oidc python -m build packages/kaya-openapi python -m build packages/kaya-cors +python -m build packages/kaya-forwarded ``` diff --git a/packages/kaya-core/src/kaya/core/__init__.py b/packages/kaya-core/src/kaya/core/__init__.py index c6b81f7..76b56e6 100644 --- a/packages/kaya-core/src/kaya/core/__init__.py +++ b/packages/kaya-core/src/kaya/core/__init__.py @@ -1,6 +1,6 @@ from ._app import AbstractKayaApp, KayaApp from ._http_method import HttpMethod -from ._http_context import HttpContext, resolve_client +from ._http_context import HttpContext from ._mixin import KayaMixin from ._tree import Tree, PathIterator from ._path_handler import PathHandler, Matches @@ -13,7 +13,6 @@ __all__ = [ 'KayaApp', 'KayaMixin', 'HttpContext', - 'resolve_client', 'Tree', 'PathHandler', 'Matches', diff --git a/packages/kaya-core/src/kaya/core/_asgi.py b/packages/kaya-core/src/kaya/core/_asgi.py index bfcb68e..0612612 100644 --- a/packages/kaya-core/src/kaya/core/_asgi.py +++ b/packages/kaya-core/src/kaya/core/_asgi.py @@ -16,7 +16,7 @@ from typing import ( from pwo import Maybe from pathlib import Path from ._http_method import HttpMethod -from ._http_context import HttpContext, resolve_client +from ._http_context import HttpContext from ._websocket import WebSocket, WebSocketMessage from ._types import StrOrStrings from ._types.asgi import HTTPScope, WebSocketScope @@ -84,9 +84,9 @@ class AsgiContext(HttpContext): self.query_string = scope['query_string'].decode() self.method = HttpMethod(scope['method']) self.scheme = scope.get('scheme', 'http') - self.headers = decode_headers(scope['headers']) - self.client = resolve_client(self.headers, scope['client']) + self.client = scope['client'] self.server = scope['server'] + self.headers = decode_headers(scope['headers']) self.request_body = request_body_iterator self.session = (scope.get('state') or {}).get('kaya_session') @@ -169,9 +169,9 @@ class AsgiWebSocket(WebSocket): self.path = scope['path'] self.query_string = scope['query_string'].decode() self.scheme = scope.get('scheme', 'ws') - self.headers = decode_headers(scope['headers']) - self.client = resolve_client(self.headers, scope['client']) + self.client = scope['client'] self.server = scope['server'] + self.headers = decode_headers(scope['headers']) async def accept(self, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: message: Dict[str, Any] = {'type': 'websocket.accept'} diff --git a/packages/kaya-core/src/kaya/core/_http_context.py b/packages/kaya-core/src/kaya/core/_http_context.py index 084c10d..c76aac3 100644 --- a/packages/kaya-core/src/kaya/core/_http_context.py +++ b/packages/kaya-core/src/kaya/core/_http_context.py @@ -16,83 +16,6 @@ from ._http_method import HttpMethod from ._types.base import StrOrStrings -def _split_host_port(value: str) -> Tuple[str, Optional[int]]: - if value.startswith('['): - # bracketed IPv6 address, optionally followed by :port - closing = value.find(']') - if closing == -1: - return value, None - host = value[1:closing] - rest = value[closing + 1:] - if rest.startswith(':'): - try: - return host, int(rest[1:]) - except ValueError: - return host, None - return host, None - if value.count(':') == 1: - host, _, port_str = value.rpartition(':') - try: - return host, int(port_str) - except ValueError: - return value, None - return value, None - - -def _first_header_value(headers: Mapping[str, Sequence[str]], name: str) -> Optional[str]: - values = headers.get(name) - if not values: - return None - first = values[0].split(',')[0].strip() - return first or None - - -def resolve_client(headers: Mapping[str, Sequence[str]], - client: Optional[Tuple[str, int]]) -> Optional[Tuple[str, int]]: - """ - Resolve the client (host, port) pair, honoring forwarded headers. - - Resolution order: - 1. the ``for=`` parameter of the first entry of the RFC 7239 ``Forwarded`` header - (a ``:port`` suffix, if present, also populates the port) - 2. the first entry of ``X-Forwarded-For`` - 3. the first entry of ``X-Forwarded-Host`` - 4. the socket peer address (``client``), returned unchanged - - In the ``X-Forwarded-*`` cases the port is taken from ``X-Forwarded-Port`` - when present and valid, otherwise the socket port is kept. - """ - socket_port = client[1] if client is not None else 0 - - forwarded_values = headers.get('forwarded') - if forwarded_values: - for raw_value in forwarded_values: - first_entry = raw_value.split(',')[0] - for param in first_entry.split(';'): - key, sep, value = param.partition('=') - if sep and key.strip().lower() == 'for': - for_value = value.strip().strip('"') - if for_value and for_value.lower() != 'unknown': - forwarded_host, forwarded_port = _split_host_port(for_value) - if forwarded_host: - return (forwarded_host, - forwarded_port if forwarded_port is not None else socket_port) - - for header_name in ('x-forwarded-for', 'x-forwarded-host'): - host = _first_header_value(headers, header_name) - if host is not None: - port = socket_port - x_forwarded_port = _first_header_value(headers, 'x-forwarded-port') - if x_forwarded_port is not None: - try: - port = int(x_forwarded_port) - except ValueError: - pass - return host, port - - return client - - class HttpContext(ABC): pathsend: bool receive: Callable[[], Awaitable[Any]] diff --git a/packages/kaya-core/tests/test_asgi.py b/packages/kaya-core/tests/test_asgi.py index 1941c92..193441b 100644 --- a/packages/kaya-core/tests/test_asgi.py +++ b/packages/kaya-core/tests/test_asgi.py @@ -62,11 +62,6 @@ class AsgiTest(unittest.TestCase): async def handle_request(ctx: HttpContext, _: List[str]) -> None: await ctx.stream_body(200, (chunk async for chunk in ctx.request_body)) - @self.app.GET('/client-ip') - async def handle_request(ctx: HttpContext) -> None: - host, port = ctx.client if ctx.client is not None else (None, None) - await ctx.send_str(200, json.dumps({'host': host, 'port': port})) - @async_test async def test_hello(self): transport = httpx.ASGITransport(app=self.app) @@ -195,60 +190,6 @@ class AsgiTest(unittest.TestCase): 'employee_id': 101325 }, response) - @async_test - async def test_client_ip_forwarded(self): - transport = httpx.ASGITransport(app=self.app) - - async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client: - # socket peer, no forwarded headers - r = await client.get("/client-ip") - socket_client = json.loads(r.text) - self.assertEqual('127.0.0.1', socket_client['host']) - - # RFC 7239 Forwarded header, with port - r = await client.get("/client-ip", headers={'Forwarded': 'for=203.0.113.5:1234'}) - self.assertEqual({'host': '203.0.113.5', 'port': 1234}, json.loads(r.text)) - - # RFC 7239 Forwarded header, bracketed IPv6 with port - r = await client.get("/client-ip", headers={'Forwarded': 'for="[2001:db8::1]:4711"'}) - self.assertEqual({'host': '2001:db8::1', 'port': 4711}, json.loads(r.text)) - - # RFC 7239 Forwarded header without port keeps the socket port - r = await client.get("/client-ip", headers={'Forwarded': 'for=203.0.113.5'}) - self.assertEqual({'host': '203.0.113.5', 'port': socket_client['port']}, json.loads(r.text)) - - # Forwarded with for=unknown falls through to X-Forwarded-For - r = await client.get("/client-ip", headers={ - 'Forwarded': 'for=unknown', - 'X-Forwarded-For': '198.51.100.7', - }) - self.assertEqual('198.51.100.7', json.loads(r.text)['host']) - - # Forwarded takes precedence over X-Forwarded-For - r = await client.get("/client-ip", headers={ - 'Forwarded': 'for=203.0.113.5', - 'X-Forwarded-For': '198.51.100.7', - }) - self.assertEqual('203.0.113.5', json.loads(r.text)['host']) - - # X-Forwarded-For: first entry of the chain, port from X-Forwarded-Port - r = await client.get("/client-ip", headers={ - 'X-Forwarded-For': '203.0.113.5, 70.41.3.18', - 'X-Forwarded-Port': '8443', - }) - self.assertEqual({'host': '203.0.113.5', 'port': 8443}, json.loads(r.text)) - - # X-Forwarded-Host fallback - r = await client.get("/client-ip", headers={'X-Forwarded-Host': '198.51.100.7'}) - self.assertEqual('198.51.100.7', json.loads(r.text)['host']) - - # invalid X-Forwarded-Port is ignored, socket port is kept - r = await client.get("/client-ip", headers={ - 'X-Forwarded-For': '203.0.113.5', - 'X-Forwarded-Port': 'not-a-port', - }) - self.assertEqual({'host': '203.0.113.5', 'port': socket_client['port']}, json.loads(r.text)) - @async_test async def test_nested_param_routes(self): app = KayaApp() diff --git a/packages/kaya-core/tests/test_websocket.py b/packages/kaya-core/tests/test_websocket.py index 4ab36f4..14120f3 100644 --- a/packages/kaya-core/tests/test_websocket.py +++ b/packages/kaya-core/tests/test_websocket.py @@ -132,37 +132,6 @@ class WebSocketTest(unittest.TestCase): self.assertEqual(1, len(sent_messages)) self.assertEqual({'type': 'websocket.accept'}, sent_messages[0]) - @async_test - async def test_client_forwarded_header(self): - async def send(message): - pass - - async def receive(): - return {'type': 'websocket.connect'} - - scope = { - 'type': 'websocket', - 'path': '/echo', - 'query_string': b'', - 'scheme': 'ws', - 'client': ('127.0.0.1', 12345), - 'server': ('127.0.0.1', 80), - 'headers': [(b'forwarded', b'for=203.0.113.5:1234')], - } - ws = AsgiWebSocket(scope, receive, send) - self.assertEqual(('203.0.113.5', 1234), ws.client) - - scope['headers'] = [ - (b'x-forwarded-for', b'203.0.113.5, 70.41.3.18'), - (b'x-forwarded-port', b'8443'), - ] - ws = AsgiWebSocket(scope, receive, send) - self.assertEqual(('203.0.113.5', 8443), ws.client) - - scope['headers'] = [] - ws = AsgiWebSocket(scope, receive, send) - self.assertEqual(('127.0.0.1', 12345), ws.client) - @async_test async def test_websocket_scope_without_scheme(self): # Daphne omits the optional `scheme` key from websocket scopes. diff --git a/packages/kaya-forwarded/README.md b/packages/kaya-forwarded/README.md new file mode 100644 index 0000000..fe8b8f9 --- /dev/null +++ b/packages/kaya-forwarded/README.md @@ -0,0 +1,61 @@ +# kaya-forwarded + +Trusted-proxy forwarded header handling for the Kaya web framework. + +Without this package, Kaya exposes the raw socket peer address as +`ctx.client` / `ws.client` and ignores `Forwarded` / `X-Forwarded-*` headers +entirely (they are client-controllable and trivially spoofable when the app is +directly exposed). + +`ForwardedHeadersMixin` opts the application into honoring those headers, but +only when the direct socket peer is a trusted proxy, identified by a list of +trusted CIDRs/IPs. + +## Usage + +```python +from kaya.core import KayaApp, HttpContext +from kaya.forwarded import ForwardedHeadersMixin + +app = KayaApp(mixins=[ + ForwardedHeadersMixin(trusted_proxies=['127.0.0.1', '::1', '10.0.0.0/8']) +]) + +@app.GET('/whoami') +async def whoami(ctx: HttpContext): + host, port = ctx.client + await ctx.send_str(200, f'{host}:{port}') +``` + +## How it works + +When a request arrives: + +1. If the socket peer IP does not belong to any trusted CIDR (or there is no + peer address), the mixin leaves the context untouched — `client` remains + the socket peer and all proxy headers are ignored. +2. Otherwise the client address is resolved from the headers, in order: + - `Forwarded` (RFC 7239): the `for=` entries are walked **from right to + left**, skipping entries that are themselves trusted proxies (and + `unknown`); the first untrusted entry is the client. This defeats + spoofing when the edge proxy *appends* to the header (e.g. nginx with + `$proxy_add_x_forwarded_for`), because attacker-supplied leftmost entries + are never selected. A `:port` in the selected `for=` value also + populates the port. + - `X-Forwarded-For`: same right-to-left trusted-proxy walk; the port comes + from `X-Forwarded-Port` when present and valid. + - `X-Forwarded-Host`: first entry; port from `X-Forwarded-Port` as above. +3. If none of the headers are present or usable, the socket peer is kept. + +If every entry in the chain is a trusted proxy, the leftmost entry is used +(the whole chain is trusted, so the leftmost is the original client). + +The resolved address is exposed by wrapping the request context / +websocket (the same pattern as `kaya-session`), so both ASGI and RSGI keep +working and `ctx.session` from other mixins is preserved. + +## Note + +Even with this mixin, the edge proxy should still strip or overwrite inbound +`Forwarded` / `X-Forwarded-*` headers from clients — the mixin protects the +application, the proxy protects the chain. diff --git a/packages/kaya-forwarded/pyproject.toml b/packages/kaya-forwarded/pyproject.toml new file mode 100644 index 0000000..cb1db02 --- /dev/null +++ b/packages/kaya-forwarded/pyproject.toml @@ -0,0 +1,56 @@ +[build-system] +requires = ["setuptools>=61.0", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[project] +name = "kaya-forwarded" +dynamic = ["version"] +authors = [ + { name="Walter Oggioni", email="oggioni.walter@gmail.com" }, +] +description = "Trusted-proxy forwarded header handling for the Kaya lightweight ASGI web framework" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +classifiers = [ + 'Development Status :: 3 - Alpha', + 'Topic :: Utilities', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Developers', + 'Environment :: Console', + 'Programming Language :: Python :: 3', +] + +dependencies = [ + "kaya-core", +] + +[project.optional-dependencies] +dev = [ + "build", "mypy", "ipdb", "twine", "httpx", "httpx-ws", "kaya-rsgi" +] + +[project.urls] +"Homepage" = "https://github.com/woggioni/kaya" +"Bug Tracker" = "https://github.com/woggioni/kaya/issues" + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = true + +[tool.mypy] +python_version = "3.12" +disallow_untyped_defs = true +show_error_codes = true +no_implicit_optional = true +warn_return_any = true +warn_unused_ignores = true +exclude = ["scripts", "docs", "test"] +strict = true + +[tool.setuptools_scm] +root = "../.." +version_file = "src/kaya/forwarded/_version.py" + +[tool.setuptools_scm.tag] +prefix = "release/" diff --git a/packages/kaya-forwarded/src/kaya/forwarded/__init__.py b/packages/kaya-forwarded/src/kaya/forwarded/__init__.py new file mode 100644 index 0000000..61c5276 --- /dev/null +++ b/packages/kaya-forwarded/src/kaya/forwarded/__init__.py @@ -0,0 +1,10 @@ +from pkgutil import extend_path + +__path__ = extend_path(__path__, __name__) + +from ._mixin import ForwardedHeadersMixin + + +__all__ = [ + 'ForwardedHeadersMixin', +] diff --git a/packages/kaya-forwarded/src/kaya/forwarded/_mixin.py b/packages/kaya-forwarded/src/kaya/forwarded/_mixin.py new file mode 100644 index 0000000..0973966 --- /dev/null +++ b/packages/kaya-forwarded/src/kaya/forwarded/_mixin.py @@ -0,0 +1,237 @@ +from ipaddress import ip_address, ip_network, IPv4Address, IPv4Network, IPv6Address, IPv6Network +from pathlib import Path +from typing import ( + Any, + AsyncGenerator, + List, + Mapping, + Optional, + Sequence, + Tuple, + Union, +) + +from kaya.core import HttpContext, KayaApp, KayaMixin, WebSocket, WebSocketMessage +from kaya.core._types import StrOrStrings + +_IPAddress = Union[IPv4Address, IPv6Address] +_IPNetwork = Union[IPv4Network, IPv6Network] + + +def _split_host_port(value: str) -> Tuple[str, Optional[int]]: + if value.startswith('['): + # bracketed IPv6 address, optionally followed by :port + closing = value.find(']') + if closing == -1: + return value, None + host = value[1:closing] + rest = value[closing + 1:] + if rest.startswith(':'): + try: + return host, int(rest[1:]) + except ValueError: + return host, None + return host, None + if value.count(':') == 1: + host, _, port_str = value.rpartition(':') + try: + return host, int(port_str) + except ValueError: + return value, None + return value, None + + +def _parse_forwarded_entries(headers: Mapping[str, Sequence[str]]) -> List[Tuple[str, Optional[int]]]: + """Extract the (host, port) `for=` entries of the RFC 7239 `Forwarded` header, + flattened across all header values, in chain order (leftmost = original client). + """ + entries: List[Tuple[str, Optional[int]]] = [] + for raw_value in headers.get('forwarded', ()): + for element in raw_value.split(','): + for param in element.split(';'): + key, sep, value = param.partition('=') + if sep and key.strip().lower() == 'for': + for_value = value.strip().strip('"') + if for_value and for_value.lower() != 'unknown': + entries.append(_split_host_port(for_value)) + break + return entries + + +def _first_header_value(headers: Mapping[str, Sequence[str]], name: str) -> Optional[str]: + values = headers.get(name) + if not values: + return None + first = values[0].split(',')[0].strip() + return first or None + + +class _ForwardedHttpContext(HttpContext): + """HttpContext wrapper that exposes the forwarded client address. + + Everything except ``client`` is delegated to the wrapped context via + ``__getattr__``, so it works with any concrete ``HttpContext`` (ASGI or + RSGI) and preserves attributes set by other mixins (e.g. ``session``). + """ + + def __init__(self, ctx: HttpContext, client: Tuple[str, int]) -> None: + object.__setattr__(self, '_ctx', ctx) + object.__setattr__(self, 'session', ctx.session) + object.__setattr__(self, 'client', client) + + def __getattr__(self, name: str) -> Any: + if name == '_ctx': + raise AttributeError(name) + return getattr(self._ctx, name) + + async def stream_body(self, + status: int, + body_generator: AsyncGenerator[bytes, None], + headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: + await self._ctx.stream_body(status, body_generator, headers) + + async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: + await self._ctx.send_bytes(status, body, headers) + + async def send_str(self, status: int, body: str, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: + await self._ctx.send_str(status, body, headers) + + async def send_file(self, status: int, path: Path, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: + await self._ctx.send_file(status, path, headers) + + async def send_empty(self, status: int, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: + await self._ctx.send_empty(status, headers) + + +class _ForwardedWebSocket(WebSocket): + """WebSocket wrapper that exposes the forwarded client address. + + Everything except ``client`` is delegated to the wrapped socket via + ``__getattr__``, so it works with any concrete ``WebSocket`` (ASGI or + RSGI) and preserves attributes set by other mixins (e.g. ``session``). + """ + + def __init__(self, ws: WebSocket, client: Tuple[str, int]) -> None: + object.__setattr__(self, '_ws', ws) + object.__setattr__(self, 'session', ws.session) + object.__setattr__(self, 'client', client) + + def __getattr__(self, name: str) -> Any: + if name == '_ws': + raise AttributeError(name) + return getattr(self._ws, name) + + async def accept(self, headers: Optional[Mapping[str, StrOrStrings]] = None) -> None: + await self._ws.accept(headers) + + async def receive(self) -> WebSocketMessage: + return await self._ws.receive() + + async def send_text(self, data: str) -> None: + await self._ws.send_text(data) + + async def send_bytes(self, data: bytes) -> None: + await self._ws.send_bytes(data) + + async def close(self, code: int = 1000) -> None: + await self._ws.close(code) + + async def __anext__(self) -> WebSocketMessage: + return await self._ws.__anext__() + + +class ForwardedHeadersMixin(KayaMixin): + """Kaya mixin that resolves the client address from proxy headers + (``Forwarded``, ``X-Forwarded-For``, ``X-Forwarded-Host``), but only when + the direct socket peer is a trusted proxy. + + Without this mixin, Kaya exposes the raw socket peer as ``ctx.client`` / + ``ws.client`` and ignores forwarded headers entirely. With the mixin + applied, forwarded headers are honored only if the socket peer IP belongs + to one of the ``trusted_proxies`` CIDRs; otherwise the context is left + untouched. + + When the peer is trusted, the address chain is walked from right to left + and entries that are themselves trusted proxies are skipped, so a client + that prepends a spoofed entry cannot fool the resolution when the edge + proxy appends to the header (e.g. nginx with ``$proxy_add_x_forwarded_for``). + + Example:: + + app = KayaApp(mixins=[ + ForwardedHeadersMixin(trusted_proxies=['127.0.0.1', '::1', '10.0.0.0/8']) + ]) + """ + + def __init__(self, trusted_proxies: Sequence[str] = ()) -> None: + self._trusted_networks: Tuple[_IPNetwork, ...] = tuple( + ip_network(cidr, strict=False) for cidr in trusted_proxies + ) + + def apply(self, app: KayaApp) -> None: + app.add_before_request_hook(self._before_request) + app.add_before_websocket_hook(self._before_websocket) + + def _is_trusted(self, host: str) -> bool: + try: + addr: _IPAddress = ip_address(host) + except ValueError: + return False + return any(addr.version == network.version and addr in network + for network in self._trusted_networks) + + def _select_forwarded_entry(self, entries: List[Tuple[str, Optional[int]]]) -> Optional[Tuple[str, Optional[int]]]: + """Walk the chain right-to-left skipping trusted proxies; the first + untrusted entry is the client. If every entry is trusted, the leftmost + (original client) is returned. + """ + for entry in reversed(entries): + if not self._is_trusted(entry[0]): + return entry + return entries[0] if entries else None + + def _forwarded_port(self, headers: Mapping[str, Sequence[str]], socket_port: int) -> int: + forwarded_port = _first_header_value(headers, 'x-forwarded-port') + if forwarded_port is not None: + try: + return int(forwarded_port) + except ValueError: + pass + return socket_port + + def _resolve(self, + headers: Mapping[str, Sequence[str]], + client: Optional[Tuple[str, int]]) -> Optional[Tuple[str, int]]: + if client is None or not self._is_trusted(client[0]): + return client + socket_port = client[1] + + selected = self._select_forwarded_entry(_parse_forwarded_entries(headers)) + if selected is not None: + host, port = selected + return host, port if port is not None else socket_port + + xff_values = headers.get('x-forwarded-for') + if xff_values: + xff_entries = [entry.strip() for value in xff_values for entry in value.split(',') if entry.strip()] + selected_host = self._select_forwarded_entry([(entry, None) for entry in xff_entries]) + if selected_host is not None: + return selected_host[0], self._forwarded_port(headers, socket_port) + + xfh = _first_header_value(headers, 'x-forwarded-host') + if xfh is not None: + return xfh, self._forwarded_port(headers, socket_port) + + return client + + async def _before_request(self, ctx: HttpContext) -> Optional[HttpContext]: + resolved = self._resolve(ctx.headers, ctx.client) + if resolved is None or resolved is ctx.client: + return None + return _ForwardedHttpContext(ctx, resolved) + + async def _before_websocket(self, ws: WebSocket) -> Optional[WebSocket]: + resolved = self._resolve(ws.headers, ws.client) + if resolved is None or resolved is ws.client: + return None + return _ForwardedWebSocket(ws, resolved) diff --git a/packages/kaya-forwarded/src/kaya/forwarded/py.typed b/packages/kaya-forwarded/src/kaya/forwarded/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/kaya-forwarded/tests/test_forwarded.py b/packages/kaya-forwarded/tests/test_forwarded.py new file mode 100644 index 0000000..e71ca24 --- /dev/null +++ b/packages/kaya-forwarded/tests/test_forwarded.py @@ -0,0 +1,244 @@ +import asyncio +import json +import unittest +from typing import Optional + +import httpx +from pwo import async_test + +from kaya.core import HttpContext, KayaApp +from kaya.core._asgi import AsgiWebSocket +from kaya.forwarded import ForwardedHeadersMixin + +TRUSTED = ['127.0.0.1', '::1', '10.0.0.0/8'] + + +def make_app(trusted_proxies=TRUSTED) -> KayaApp: + mixins = [ForwardedHeadersMixin(trusted_proxies=trusted_proxies)] if trusted_proxies is not None else [] + app = KayaApp(mixins=mixins) + + @app.GET('/client') + async def client(ctx: HttpContext) -> None: + host, port = ctx.client if ctx.client is not None else (None, None) + await ctx.send_str(200, json.dumps({'host': host, 'port': port})) + + return app + + +async def request(app: KayaApp, + headers: Optional[dict[str, str]] = None, + client: tuple[str, int] = ('127.0.0.1', 123)) -> dict: + transport = httpx.ASGITransport(app=app, client=client) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as http_client: + r = await http_client.get('/client', headers=headers) + assert r.status_code == 200 + return json.loads(r.text) + + +class TrustedPeerTest(unittest.TestCase): + + @async_test + async def test_no_headers_returns_socket_peer(self): + app = make_app() + result = await request(app) + self.assertEqual({'host': '127.0.0.1', 'port': 123}, result) + + @async_test + async def test_forwarded_header_with_port(self): + app = make_app() + result = await request(app, headers={'Forwarded': 'for=203.0.113.5:1234'}) + self.assertEqual({'host': '203.0.113.5', 'port': 1234}, result) + + @async_test + async def test_forwarded_header_bracketed_ipv6(self): + app = make_app() + result = await request(app, headers={'Forwarded': 'for="[2001:db8::1]:4711"'}) + self.assertEqual({'host': '2001:db8::1', 'port': 4711}, result) + + @async_test + async def test_forwarded_header_without_port_keeps_socket_port(self): + app = make_app() + result = await request(app, headers={'Forwarded': 'for=203.0.113.5'}) + self.assertEqual({'host': '203.0.113.5', 'port': 123}, result) + + @async_test + async def test_forwarded_unknown_entry_skipped(self): + app = make_app() + result = await request(app, headers={'Forwarded': 'for=unknown, for=203.0.113.5'}) + self.assertEqual('203.0.113.5', result['host']) + + @async_test + async def test_forwarded_rightmost_untrusted_wins(self): + # attacker-controlled leftmost entry is skipped: the rightmost + # untrusted entry (appended by the trusted edge proxy) is the client + app = make_app() + result = await request(app, headers={'Forwarded': 'for=1.2.3.4, for=5.6.7.8, for=10.0.0.2'}) + self.assertEqual('5.6.7.8', result['host']) + + @async_test + async def test_x_forwarded_for_spoofed_leftmost_entry_skipped(self): + # XFF = ", " as appended by the proxy + app = make_app() + result = await request(app, headers={'X-Forwarded-For': '1.2.3.4, 5.6.7.8'}) + self.assertEqual('5.6.7.8', result['host']) + + @async_test + async def test_x_forwarded_for_all_trusted_chain_uses_leftmost(self): + app = make_app() + result = await request(app, headers={'X-Forwarded-For': '10.0.0.5, 10.0.0.2'}) + self.assertEqual('10.0.0.5', result['host']) + + @async_test + async def test_x_forwarded_port(self): + app = make_app() + result = await request(app, headers={ + 'X-Forwarded-For': '203.0.113.5', + 'X-Forwarded-Port': '8443', + }) + self.assertEqual({'host': '203.0.113.5', 'port': 8443}, result) + + @async_test + async def test_invalid_x_forwarded_port_ignored(self): + app = make_app() + result = await request(app, headers={ + 'X-Forwarded-For': '203.0.113.5', + 'X-Forwarded-Port': 'not-a-port', + }) + self.assertEqual({'host': '203.0.113.5', 'port': 123}, result) + + @async_test + async def test_forwarded_takes_precedence_over_x_forwarded_for(self): + app = make_app() + result = await request(app, headers={ + 'Forwarded': 'for=203.0.113.5', + 'X-Forwarded-For': '198.51.100.7', + }) + self.assertEqual('203.0.113.5', result['host']) + + @async_test + async def test_x_forwarded_host_fallback(self): + app = make_app() + result = await request(app, headers={'X-Forwarded-Host': '198.51.100.7'}) + self.assertEqual('198.51.100.7', result['host']) + + @async_test + async def test_ipv6_cidr_trust(self): + app = make_app(trusted_proxies=['2001:db8::/32']) + result = await request(app, + headers={'X-Forwarded-For': '203.0.113.5'}, + client=('2001:db8::10', 9999)) + self.assertEqual({'host': '203.0.113.5', 'port': 9999}, result) + + +class UntrustedPeerTest(unittest.TestCase): + + @async_test + async def test_untrusted_peer_ignores_forwarded_headers(self): + app = make_app() + result = await request(app, + headers={'Forwarded': 'for=1.2.3.4', 'X-Forwarded-For': '1.2.3.4'}, + client=('203.0.113.99', 4567)) + self.assertEqual({'host': '203.0.113.99', 'port': 4567}, result) + + @async_test + async def test_empty_trusted_proxies_ignores_everything(self): + app = make_app(trusted_proxies=[]) + result = await request(app, headers={'X-Forwarded-For': '1.2.3.4'}) + self.assertEqual({'host': '127.0.0.1', 'port': 123}, result) + + @async_test + async def test_invalid_cidr_fails_fast(self): + with self.assertRaises(ValueError): + ForwardedHeadersMixin(trusted_proxies=['not-a-cidr']) + + +class OptOutTest(unittest.TestCase): + + @async_test + async def test_without_mixin_headers_are_ignored(self): + app = KayaApp() + + @app.GET('/client') + async def client(ctx: HttpContext) -> None: + host, port = ctx.client if ctx.client is not None else (None, None) + await ctx.send_str(200, json.dumps({'host': host, 'port': port})) + + result = await request(app, headers={'Forwarded': 'for=1.2.3.4', 'X-Forwarded-For': '1.2.3.4'}) + self.assertEqual({'host': '127.0.0.1', 'port': 123}, result) + + +class WebSocketTest(unittest.TestCase): + + @staticmethod + def _make_ws(headers): + async def send(message): + pass + + async def receive(): + return {'type': 'websocket.connect'} + + scope = { + 'type': 'websocket', + 'path': '/ws', + 'query_string': b'', + 'scheme': 'ws', + 'client': ('127.0.0.1', 12345), + 'server': ('127.0.0.1', 80), + 'headers': headers, + } + return AsgiWebSocket(scope, receive, send) + + @async_test + async def test_websocket_trusted_peer(self): + mixin = ForwardedHeadersMixin(trusted_proxies=TRUSTED) + ws = self._make_ws([(b'x-forwarded-for', b'1.2.3.4, 5.6.7.8')]) + wrapped = await mixin._before_websocket(ws) + assert wrapped is not None + self.assertEqual(('5.6.7.8', 12345), wrapped.client) + + @async_test + async def test_websocket_untrusted_peer(self): + mixin = ForwardedHeadersMixin(trusted_proxies=['10.0.0.0/8']) + ws = self._make_ws([(b'x-forwarded-for', b'1.2.3.4')]) + wrapped = await mixin._before_websocket(ws) + self.assertIsNone(wrapped) + self.assertEqual(('127.0.0.1', 12345), ws.client) + + +class RsgiTest(unittest.TestCase): + + def test_rsgi_context(self): + from kaya.rsgi import RsgiContext + + class FakeScope: + scheme = 'http' + method = 'GET' + path = '/' + query_string = '' + headers = {'x-forwarded-for': '1.2.3.4, 5.6.7.8'} + client = '127.0.0.1:12345' + server = '127.0.0.1:80' + + mixin = ForwardedHeadersMixin(trusted_proxies=TRUSTED) + ctx = RsgiContext(FakeScope(), object()) # type: ignore[arg-type] + wrapped = asyncio.run(mixin._before_request(ctx)) + assert wrapped is not None + self.assertEqual(('5.6.7.8', 12345), wrapped.client) + + def test_rsgi_context_untrusted_peer(self): + from kaya.rsgi import RsgiContext + + class FakeScope: + scheme = 'http' + method = 'GET' + path = '/' + query_string = '' + headers = {'x-forwarded-for': '1.2.3.4'} + client = '192.0.2.1:12345' + server = '127.0.0.1:80' + + mixin = ForwardedHeadersMixin(trusted_proxies=TRUSTED) + ctx = RsgiContext(FakeScope(), object()) # type: ignore[arg-type] + wrapped = asyncio.run(mixin._before_request(ctx)) + self.assertIsNone(wrapped) + self.assertEqual(('192.0.2.1', 12345), ctx.client) diff --git a/packages/kaya-rsgi/src/kaya/rsgi/_rsgi.py b/packages/kaya-rsgi/src/kaya/rsgi/_rsgi.py index 97aa28e..774cbe6 100644 --- a/packages/kaya-rsgi/src/kaya/rsgi/_rsgi.py +++ b/packages/kaya-rsgi/src/kaya/rsgi/_rsgi.py @@ -23,7 +23,7 @@ from granian._granian import ( # type: ignore[attr-defined] ) from pwo import Maybe -from kaya.core import AbstractKayaApp, HttpContext, HttpMethod, WebSocket, WebSocketMessage, resolve_client +from kaya.core import AbstractKayaApp, HttpContext, HttpMethod, WebSocket, WebSocketMessage from kaya.core._types import StrOrStrings @@ -51,10 +51,9 @@ class RsgiContext(HttpContext): fun = cast(Callable[[Mapping[str, Sequence[str]], tuple[str, str]], Mapping[str, Sequence[str]]], acc) self.headers = reduce(fun, scope.headers.items(), {}) - self.client = resolve_client(self.headers, - Maybe.of(scope.client.split(':')) - .map(lambda it: (it[0], int(it[1]))) - .or_else_throw(RuntimeError)) + self.client = (Maybe.of(scope.client.split(':')) + .map(lambda it: (it[0], int(it[1]))) + .or_else_throw(RuntimeError)) self.server = (Maybe.of(scope.server.split(':')) .map(lambda it: (it[0], int(it[1]))) .or_else_throw(RuntimeError)) @@ -135,10 +134,9 @@ class RsgiWebSocket(WebSocket): fun = cast(Callable[[Mapping[str, Sequence[str]], tuple[str, str]], Mapping[str, Sequence[str]]], acc) self.headers = reduce(fun, scope.headers.items(), {}) - self.client = resolve_client(self.headers, - Maybe.of(scope.client.split(':')) - .map(lambda it: (it[0], int(it[1]))) - .or_else_throw(RuntimeError)) + self.client = (Maybe.of(scope.client.split(':')) + .map(lambda it: (it[0], int(it[1]))) + .or_else_throw(RuntimeError)) self.server = (Maybe.of(scope.server.split(':')) .map(lambda it: (it[0], int(it[1]))) .or_else_throw(RuntimeError)) diff --git a/packages/kaya-rsgi/tests/test_rsgi.py b/packages/kaya-rsgi/tests/test_rsgi.py index 9d7ae51..096850b 100644 --- a/packages/kaya-rsgi/tests/test_rsgi.py +++ b/packages/kaya-rsgi/tests/test_rsgi.py @@ -1,5 +1,5 @@ import unittest -from kaya.rsgi import RsgiContext, RsgiWebSocket +from kaya.rsgi import RsgiWebSocket class RsgiWebSocketTest(unittest.TestCase): @@ -20,40 +20,3 @@ class RsgiWebSocketTest(unittest.TestCase): RsgiWebSocket(FakeScope(), FakeProtocol()) # type: ignore[arg-type] self.assertIn('Granian was not configured for websockets', str(ctx.exception)) - - -class RsgiContextTest(unittest.TestCase): - - @staticmethod - def _make_context(headers): - class FakeScope: - scheme = 'http' - method = 'GET' - path = '/' - query_string = '' - client = '127.0.0.1:12345' - server = '127.0.0.1:80' - - def __init__(self, headers): - self.headers = headers - - return RsgiContext(FakeScope(headers), object()) # type: ignore[arg-type] - - def test_forwarded_header(self): - ctx = self._make_context({'forwarded': 'for=203.0.113.5:1234'}) - self.assertEqual(('203.0.113.5', 1234), ctx.client) - - def test_x_forwarded_headers(self): - ctx = self._make_context({ - 'x-forwarded-for': '203.0.113.5, 70.41.3.18', - 'x-forwarded-port': '8443', - }) - self.assertEqual(('203.0.113.5', 8443), ctx.client) - - def test_x_forwarded_host_fallback(self): - ctx = self._make_context({'x-forwarded-host': '198.51.100.7'}) - self.assertEqual(('198.51.100.7', 12345), ctx.client) - - def test_socket_peer_fallback(self): - ctx = self._make_context({}) - self.assertEqual(('127.0.0.1', 12345), ctx.client) diff --git a/requirements-dev.in b/requirements-dev.in index 40fdde9..5066501 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -6,6 +6,7 @@ kaya-session-memcache @ file:./packages/kaya-session-memcache kaya-oidc @ file:./packages/kaya-oidc kaya-openapi @ file:./packages/kaya-openapi kaya-cors @ file:./packages/kaya-cors +kaya-forwarded @ file:./packages/kaya-forwarded build fakeredis mypy diff --git a/requirements-dev.txt b/requirements-dev.txt index 8ca0c52..a652173 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -90,12 +90,15 @@ file:./packages/kaya-core # via # -r requirements-dev.in # kaya-cors + # kaya-forwarded # kaya-oidc # kaya-openapi # kaya-rsgi # kaya-session file:./packages/kaya-cors # via -r requirements-dev.in +file:./packages/kaya-forwarded + # via -r requirements-dev.in file:./packages/kaya-oidc # via -r requirements-dev.in file:./packages/kaya-openapi