Upgrade to kaya 0.0.3 with trusted-proxy forwarded header support
- 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:
@@ -1,3 +1,8 @@
|
|||||||
# Name used in the HTML page title/heading and the curl examples
|
# Name used in the HTML page title/heading and the curl examples
|
||||||
# (e.g. "ifconfig.me" when deployed under that domain).
|
# (e.g. "ifconfig.me" when deployed under that domain).
|
||||||
SITE_NAME=pyfconfig
|
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
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ over the RSGI protocol.
|
|||||||
|
|
||||||
- **kaya-core** — routing and HTTP request/response handling
|
- **kaya-core** — routing and HTTP request/response handling
|
||||||
- **kaya-rsgi** — Granian (RSGI) adapter
|
- **kaya-rsgi** — Granian (RSGI) adapter
|
||||||
|
- **kaya-forwarded** — trusted-proxy handling of `Forwarded` / `X-Forwarded-*` headers
|
||||||
- **granian** — application server
|
- **granian** — application server
|
||||||
- **rloop** — Rust event loop used by Granian instead of the stdlib asyncio loop
|
- **rloop** — Rust event loop used by Granian instead of the stdlib asyncio loop
|
||||||
- **httpx + pwo** — test client over kaya's ASGI transport
|
- **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`.
|
`keep_alive`, `method`, `encoding`, `mime`, `charset`, `via`, `forwarded`.
|
||||||
|
|
||||||
The reported client IP/port honor the `Forwarded`, `X-Forwarded-For`,
|
The reported client IP/port honor the `Forwarded`, `X-Forwarded-For`,
|
||||||
`X-Forwarded-Host` and `X-Forwarded-Port` proxy headers (kaya ≥ 0.0.2);
|
`X-Forwarded-Host` and `X-Forwarded-Port` proxy headers **only when the
|
||||||
without them the socket peer address is used.
|
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:
|
Example:
|
||||||
|
|
||||||
@@ -80,6 +85,7 @@ Environment variables (see `.env.example`):
|
|||||||
| Variable | Default | Description |
|
| 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`) |
|
| `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` /
|
The bind address is configured through Granian itself (`GRANIAN_HOST` /
|
||||||
`GRANIAN_PORT` env vars or `--host` / `--port` CLI flags).
|
`GRANIAN_PORT` env vars or `--host` / `--port` CLI flags).
|
||||||
|
|||||||
@@ -7,5 +7,9 @@ services:
|
|||||||
# Public name shown in the HTML page and the curl examples; set to the
|
# Public name shown in the HTML page and the curl examples; set to the
|
||||||
# deployment domain (e.g. "ifconfig.example.com").
|
# deployment domain (e.g. "ifconfig.example.com").
|
||||||
SITE_NAME: ${SITE_NAME:-pyfconfig}
|
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:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
|
|||||||
+3
-2
@@ -9,8 +9,9 @@ description = "A clone of https://ifconfig.me/ built on the kaya framework"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaya-core>=0.0.2",
|
"kaya-core>=0.0.3",
|
||||||
"kaya-rsgi>=0.0.2",
|
"kaya-rsgi>=0.0.3",
|
||||||
|
"kaya-forwarded>=0.0.3",
|
||||||
"granian>=2.0",
|
"granian>=2.0",
|
||||||
"httpx",
|
"httpx",
|
||||||
"pwo",
|
"pwo",
|
||||||
|
|||||||
+5
-2
@@ -29,11 +29,14 @@ idna==3.19
|
|||||||
# via
|
# via
|
||||||
# anyio
|
# anyio
|
||||||
# httpx
|
# httpx
|
||||||
kaya-core==0.0.2
|
kaya-core==0.0.3
|
||||||
# via
|
# via
|
||||||
|
# kaya-forwarded
|
||||||
# kaya-rsgi
|
# kaya-rsgi
|
||||||
# pyfconfig (pyproject.toml)
|
# 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)
|
# via pyfconfig (pyproject.toml)
|
||||||
pwo==0.1.2
|
pwo==0.1.2
|
||||||
# via
|
# via
|
||||||
|
|||||||
@@ -2,8 +2,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from kaya.core import KayaApp
|
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
|
# Register routes by importing modules. Order does not matter; each module
|
||||||
# pulls ``app`` from here and decorates its handlers at import time.
|
# pulls ``app`` from here and decorates its handlers at import time.
|
||||||
|
|||||||
+20
-2
@@ -6,8 +6,9 @@ dataclass. No pydantic-settings, no settings module.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from ipaddress import ip_network
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
def _env(name: str, default: Optional[str] = None) -> str:
|
def _env(name: str, default: Optional[str] = None) -> str:
|
||||||
@@ -19,9 +20,22 @@ def _env(name: str, default: Optional[str] = None) -> str:
|
|||||||
return value
|
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)
|
@dataclass(frozen=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
site_name: str
|
site_name: str
|
||||||
|
trusted_proxy_cidrs: Tuple[str, ...] = field(default=())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_env() -> "Settings":
|
def from_env() -> "Settings":
|
||||||
@@ -29,6 +43,10 @@ class Settings:
|
|||||||
# Public name of the deployment, used in the HTML page title
|
# Public name of the deployment, used in the HTML page title
|
||||||
# and in the command-line examples (e.g. "ifconfig.me").
|
# and in the command-line examples (e.g. "ifconfig.me").
|
||||||
site_name=_env("SITE_NAME", "pyfconfig"),
|
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
@@ -1,6 +1,13 @@
|
|||||||
"""Test package init.
|
"""Test package init.
|
||||||
|
|
||||||
pyfconfig's settings all have safe defaults, so no environment overrides
|
``TRUSTED_PROXY_CIDRS`` must be set before :mod:`pyfconfig.config` is first
|
||||||
are required before importing :mod:`pyfconfig.app`; this file exists so
|
imported because settings are read from the environment at import time.
|
||||||
``python -m unittest discover -s tests -t .`` treats tests as a package.
|
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
@@ -12,9 +12,10 @@ from pyfconfig.app import app
|
|||||||
# httpx's ASGITransport populates the scope with this client tuple.
|
# httpx's ASGITransport populates the scope with this client tuple.
|
||||||
CLIENT_IP = "127.0.0.1"
|
CLIENT_IP = "127.0.0.1"
|
||||||
CLIENT_PORT = "123"
|
CLIENT_PORT = "123"
|
||||||
# First entry of the X-Forwarded-For header in ALL_HEADERS; since kaya 0.0.2
|
# First entry of the X-Forwarded-For header in ALL_HEADERS. With
|
||||||
# ctx.client honors forwarded headers, ip_addr resolves to this instead of
|
# kaya-forwarded (see tests/__init__.py for the trusted CIDRs), the chain is
|
||||||
# the socket peer address.
|
# 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"
|
FORWARDED_IP = "203.0.113.7"
|
||||||
|
|
||||||
ALL_HEADERS = {
|
ALL_HEADERS = {
|
||||||
@@ -58,6 +59,31 @@ class RoutesTest(unittest.TestCase):
|
|||||||
r = await client.get("/ip", headers={"X-Forwarded-For": "203.0.113.7"})
|
r = await client.get("/ip", headers={"X-Forwarded-For": "203.0.113.7"})
|
||||||
self.assertEqual("203.0.113.7", r.text.strip())
|
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_test
|
||||||
async def test_ua(self) -> None:
|
async def test_ua(self) -> None:
|
||||||
async with self.client() as client:
|
async with self.client() as client:
|
||||||
|
|||||||
Reference in New Issue
Block a user