Upgrade to kaya 0.0.3 with trusted-proxy forwarded header support
CI / Build and push docker image (push) Successful in 1m25s

- bump kaya-core/kaya-rsgi to >= 0.0.3 and add kaya-forwarded: forwarded
  header handling is no longer built into core, it is opt-in via
  ForwardedHeadersMixin and gated on trusted proxy CIDRs
- add TRUSTED_PROXY_CIDRS setting (comma-separated CIDRs, validated at
  startup; empty means no proxy is trusted) and wire the mixin in app.py
- cover trusted/untrusted peers, all-trusted chains and the RFC 7239
  Forwarded header with port in the test suite
- document the new variable in README, .env.example and docker-compose.yml
This commit is contained in:
2026-09-05 16:18:37 +08:00
committed by woggioni
parent d51f380a3f
commit 47d2280970
9 changed files with 93 additions and 15 deletions
+5
View File
@@ -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
+8 -2
View File
@@ -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).
+4
View File
@@ -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"
+3 -2
View File
@@ -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",
+5 -2
View File
@@ -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
+9 -1
View File
@@ -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.
+20 -2
View File
@@ -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"),
)
+10 -3
View File
@@ -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")
+29 -3
View File
@@ -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: