diff --git a/.env.example b/.env.example index 6bb25b4..047b649 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,8 @@ # Name used in the HTML page title/heading and the curl examples # (e.g. "ifconfig.me" when deployed under that domain). SITE_NAME=pyfconfig + +# Comma-separated CIDRs/IPs of trusted reverse proxies. Forwarded / +# X-Forwarded-* headers are honored only when the direct peer belongs to +# one of these; leave empty when the app is directly exposed. +# TRUSTED_PROXY_CIDRS=127.0.0.1,10.0.0.0/8 diff --git a/README.md b/README.md index d9c6d2e..f7aefe6 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ over the RSGI protocol. - **kaya-core** — routing and HTTP request/response handling - **kaya-rsgi** — Granian (RSGI) adapter +- **kaya-forwarded** — trusted-proxy handling of `Forwarded` / `X-Forwarded-*` headers - **granian** — application server - **rloop** — Rust event loop used by Granian instead of the stdlib asyncio loop - **httpx + pwo** — test client over kaya's ASGI transport @@ -36,8 +37,12 @@ The `/all` field order mirrors the reference site: `ip_addr`, `keep_alive`, `method`, `encoding`, `mime`, `charset`, `via`, `forwarded`. The reported client IP/port honor the `Forwarded`, `X-Forwarded-For`, -`X-Forwarded-Host` and `X-Forwarded-Port` proxy headers (kaya ≥ 0.0.2); -without them the socket peer address is used. +`X-Forwarded-Host` and `X-Forwarded-Port` proxy headers **only when the +direct peer belongs to one of the `TRUSTED_PROXY_CIDRS`** (see +[Configuration](#configuration)); the header chain is walked right-to-left +skipping trusted proxies, so spoofed entries prepended by the client are +never selected. Without trusted proxies configured, or when the peer is +untrusted, the socket peer address is used. Example: @@ -80,6 +85,7 @@ Environment variables (see `.env.example`): | Variable | Default | Description | |---|---|---| | `SITE_NAME` | `pyfconfig` | Public name used in the HTML page title and the curl examples (set to your domain, e.g. `ifconfig.example.com`) | +| `TRUSTED_PROXY_CIDRS` | *(empty)* | Comma-separated CIDRs/IPs of trusted reverse proxies (e.g. `127.0.0.1,10.0.0.0/8`). Forwarded headers are honored only from these peers; empty means no proxy is trusted | The bind address is configured through Granian itself (`GRANIAN_HOST` / `GRANIAN_PORT` env vars or `--host` / `--port` CLI flags). diff --git a/docker-compose.yml b/docker-compose.yml index c944a9b..ea5f723 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,5 +7,9 @@ services: # Public name shown in the HTML page and the curl examples; set to the # deployment domain (e.g. "ifconfig.example.com"). SITE_NAME: ${SITE_NAME:-pyfconfig} + # Comma-separated CIDRs of trusted reverse proxies (e.g. + # "172.16.0.0/12"); required for correct client IPs when deployed + # behind a reverse proxy. Empty means no proxy is trusted. + TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-} ports: - "127.0.0.1:8080:8080" diff --git a/pyproject.toml b/pyproject.toml index ea0057f..ee37ffa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,9 @@ description = "A clone of https://ifconfig.me/ built on the kaya framework" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "kaya-core>=0.0.2", - "kaya-rsgi>=0.0.2", + "kaya-core>=0.0.3", + "kaya-rsgi>=0.0.3", + "kaya-forwarded>=0.0.3", "granian>=2.0", "httpx", "pwo", diff --git a/requirements.txt b/requirements.txt index 6613247..14d4100 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,11 +29,14 @@ idna==3.19 # via # anyio # httpx -kaya-core==0.0.2 +kaya-core==0.0.3 # via + # kaya-forwarded # kaya-rsgi # pyfconfig (pyproject.toml) -kaya-rsgi==0.0.2 +kaya-forwarded==0.0.3 + # via pyfconfig (pyproject.toml) +kaya-rsgi==0.0.3 # via pyfconfig (pyproject.toml) pwo==0.1.2 # via diff --git a/src/pyfconfig/app.py b/src/pyfconfig/app.py index 9b16bab..b36216e 100644 --- a/src/pyfconfig/app.py +++ b/src/pyfconfig/app.py @@ -2,8 +2,16 @@ from __future__ import annotations from kaya.core import KayaApp +from kaya.forwarded import ForwardedHeadersMixin -app = KayaApp() +from .config import settings + +# Honor Forwarded / X-Forwarded-* headers, but only when the direct peer is a +# trusted proxy (see TRUSTED_PROXY_CIDRS). With no trusted CIDRs configured +# the mixin is a no-op pass-through. +app = KayaApp(mixins=[ + ForwardedHeadersMixin(trusted_proxies=settings.trusted_proxy_cidrs), +]) # Register routes by importing modules. Order does not matter; each module # pulls ``app`` from here and decorates its handlers at import time. diff --git a/src/pyfconfig/config.py b/src/pyfconfig/config.py index af436b8..c257a22 100644 --- a/src/pyfconfig/config.py +++ b/src/pyfconfig/config.py @@ -6,8 +6,9 @@ dataclass. No pydantic-settings, no settings module. from __future__ import annotations import os -from dataclasses import dataclass -from typing import Optional +from dataclasses import dataclass, field +from ipaddress import ip_network +from typing import Optional, Tuple def _env(name: str, default: Optional[str] = None) -> str: @@ -19,9 +20,22 @@ def _env(name: str, default: Optional[str] = None) -> str: return value +def _cidrs(name: str) -> Tuple[str, ...]: + """Parse a comma-separated list of CIDRs/IPs, validating each entry.""" + raw = os.environ.get(name) or "" + entries = tuple(entry.strip() for entry in raw.split(",") if entry.strip()) + for entry in entries: + try: + ip_network(entry, strict=False) + except ValueError as exc: + raise RuntimeError(f"Invalid CIDR in {name}: {entry!r}") from exc + return entries + + @dataclass(frozen=True) class Settings: site_name: str + trusted_proxy_cidrs: Tuple[str, ...] = field(default=()) @staticmethod def from_env() -> "Settings": @@ -29,6 +43,10 @@ class Settings: # Public name of the deployment, used in the HTML page title # and in the command-line examples (e.g. "ifconfig.me"). site_name=_env("SITE_NAME", "pyfconfig"), + # Comma-separated CIDRs/IPs of trusted reverse proxies; forwarded + # headers are honored only when the socket peer belongs to one of + # them. Empty (the default) means no proxy is trusted. + trusted_proxy_cidrs=_cidrs("TRUSTED_PROXY_CIDRS"), ) diff --git a/tests/__init__.py b/tests/__init__.py index 14097da..6fd15b7 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,6 +1,13 @@ """Test package init. -pyfconfig's settings all have safe defaults, so no environment overrides -are required before importing :mod:`pyfconfig.app`; this file exists so -``python -m unittest discover -s tests -t .`` treats tests as a package. +``TRUSTED_PROXY_CIDRS`` must be set before :mod:`pyfconfig.config` is first +imported because settings are read from the environment at import time. +The ASGI transport used by the tests presents ``127.0.0.1`` as the socket +peer, so it is trusted here together with ``10.0.0.0/8`` to exercise the +right-to-left trusted-proxy chain walk. """ +from __future__ import annotations + +import os + +os.environ.setdefault("TRUSTED_PROXY_CIDRS", "127.0.0.1,10.0.0.0/8") diff --git a/tests/test_routes.py b/tests/test_routes.py index 5852d31..d5b9ad1 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -12,9 +12,10 @@ from pyfconfig.app import app # httpx's ASGITransport populates the scope with this client tuple. CLIENT_IP = "127.0.0.1" CLIENT_PORT = "123" -# First entry of the X-Forwarded-For header in ALL_HEADERS; since kaya 0.0.2 -# ctx.client honors forwarded headers, ip_addr resolves to this instead of -# the socket peer address. +# First entry of the X-Forwarded-For header in ALL_HEADERS. With +# kaya-forwarded (see tests/__init__.py for the trusted CIDRs), the chain is +# walked right-to-left skipping trusted proxies: 10.0.0.1 is trusted, so +# ip_addr resolves to this instead of the socket peer address. FORWARDED_IP = "203.0.113.7" ALL_HEADERS = { @@ -58,6 +59,31 @@ class RoutesTest(unittest.TestCase): r = await client.get("/ip", headers={"X-Forwarded-For": "203.0.113.7"}) self.assertEqual("203.0.113.7", r.text.strip()) + @async_test + async def test_ip_ignores_x_forwarded_for_from_untrusted_peer(self) -> None: + # The socket peer is not in TRUSTED_PROXY_CIDRS, so proxy headers + # are ignored and the peer address itself is reported. + transport = ASGITransport(app=app, client=("192.0.2.10", 5555)) + async with AsyncClient(transport=transport, base_url="http://192.0.2.10") as client: + r = await client.get("/ip", headers={"X-Forwarded-For": "203.0.113.7"}) + self.assertEqual("192.0.2.10", r.text.strip()) + + @async_test + async def test_ip_all_trusted_chain_uses_leftmost_entry(self) -> None: + # 10.1.2.3 is inside the trusted 10.0.0.0/8, so the whole chain is + # trusted and the leftmost entry is the original client. + async with self.client() as client: + r = await client.get("/ip", headers={"X-Forwarded-For": "10.1.2.3"}) + self.assertEqual("10.1.2.3", r.text.strip()) + + @async_test + async def test_forwarded_header_with_port(self) -> None: + async with self.client() as client: + r = await client.get("/all.json", headers={"Forwarded": "for=203.0.113.7:4455"}) + data = json.loads(r.text) + self.assertEqual("203.0.113.7", data["ip_addr"]) + self.assertEqual("4455", data["port"]) + @async_test async def test_ua(self) -> None: async with self.client() as client: