Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ JWT_ISSUER=fastapi-production-api
CORS_ORIGINS=http://localhost:3000


# Trusted reverse-proxy socket peers. Leave empty when connecting directly.
FORWARDED_ALLOW_IPS=


# Redis and request rate limiting. Memory mode preserves single-process setup.
REDIS_URL=redis://localhost:6379/0
REDIS_CONNECT_TIMEOUT_SECONDS=1
Expand Down
5 changes: 5 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ JWT_ISSUER=fastapi-production-api
CORS_ORIGINS=https://your-domain.com


# Exact reverse-proxy peers/CIDRs; never use `*`.

FORWARDED_ALLOW_IPS=127.0.0.1


# Redis and distributed request rate limiting

REDIS_URL=rediss://app:password@redis.internal.example:6379/0
Expand Down
18 changes: 11 additions & 7 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ flowchart LR
Metrics --> App
```

The reverse proxy terminates TLS and controls trusted forwarding headers. The
application owns validation, authorization, business transactions, and
telemetry. PostgreSQL is required; Redis becomes a required readiness dependency
when distributed rate limiting is enabled.
The reverse proxy terminates TLS and replaces untrusted forwarding headers.
Uvicorn accepts those headers only from the explicit `FORWARDED_ALLOW_IPS`
address/CIDR allowlist, then exposes one canonical ASGI client address to both
request logging and rate limiting. The application owns validation,
authorization, business transactions, and telemetry. PostgreSQL is required;
Redis becomes a required readiness dependency when distributed rate limiting is
enabled.

## Source layout and responsibilities

Expand Down Expand Up @@ -214,9 +217,10 @@ disabled delivery does not create unreachable tokens. MFA and OIDC transaction
data use dedicated encryption keys rather than the JWT signing secret. Redis
quota keys contain only versioned HMAC identifiers and bounded fixed-window
counters. OIDC cache keys contain a fixed issuer digest, document kind, and
bounded TTL; values contain only public discovery/JWKS JSON. CORS origins, proxy
trust, metrics exposure, database permissions, and secret storage remain
deployment responsibilities.
bounded TTL; values contain only public discovery/JWKS JSON. Proxy trust is
fail-closed by default but its exact CIDRs, along with CORS origins, metrics
exposure, database permissions, and secret storage, remain deployment
responsibilities.

## Safe extension points

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- Explicit trusted-proxy IP/CIDR allowlisting for Uvicorn client-address
resolution, with canonical client IPs shared by rate limiting and request logs
- Administrative account disable/re-enable lifecycle with immediate access
rejection and atomic refresh-session revocation
- Optional Redis-backed fixed-window rate limiting shared across API processes
Expand Down
23 changes: 17 additions & 6 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,7 @@ server {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}

Expand All @@ -287,10 +286,21 @@ sudo nginx -t
sudo systemctl reload nginx
```

Only trust forwarded headers from proxies you control. Configure Uvicorn's
forwarded allowlist (for example `FORWARDED_ALLOW_IPS=127.0.0.1` for same-host
Nginx) and firewall the Gunicorn port from untrusted clients. The application
uses the ASGI scope client address and never parses forwarding headers itself.
`FORWARDED_ALLOW_IPS` is the trust boundary for both client IP and forwarded
scheme resolution. Set it to the socket peers that connect directly to
Gunicorn, for example `127.0.0.1` for same-host Nginx or a comma-separated list
of canonical proxy CIDRs. The default is empty, and the application rejects
wildcard, malformed, and non-canonical entries. Never list client networks.

The single-proxy example overwrites `X-Forwarded-For` with `$remote_addr`, so a
client-supplied value cannot enter the trusted chain. For a controlled
multi-proxy topology, each trusted proxy may append its verified peer address;
list every direct intermediary CIDR and test the chain before deployment.

Uvicorn validates the socket peer before updating the ASGI client address. Rate
limiting and request logs consume only that canonical ASGI address and do not
parse forwarding headers again. Keep the Gunicorn port firewalled from
untrusted clients even when the allowlist is configured.

## 8. Enable HTTPS

Expand Down Expand Up @@ -386,6 +396,7 @@ trace-context columns; tracing metadata is not a correctness dependency.
- [ ] Run test, lint, and dependency-audit jobs successfully
- [ ] Restrict database and service-account permissions
- [ ] Restrict the application port to the trusted proxy
- [ ] Set `FORWARDED_ALLOW_IPS` to direct proxy peers only and test spoofed headers
- [ ] Enable HTTPS and renewal monitoring
- [ ] Configure logs, metrics, alerts, and retention
- [ ] Configure database backups and test restoration
Expand Down
4 changes: 3 additions & 1 deletion MONITORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ configuration marks worker gauge files as dead when workers exit.

Every application log is a single JSON object containing `timestamp`, `level`,
`logger`, `message`, and `request_id`. HTTP completion records also contain
`method`, `path`, `route`, `status_code`, and `duration_ms`.
`method`, canonical `client_ip`, `path`, `route`, `status_code`, and
`duration_ms`. Treat client addresses as personal or security-sensitive data:
restrict log access and choose a retention period appropriate to local policy.

When a valid OpenTelemetry span is active, the same JSON record also contains
hexadecimal `trace_id` and `span_id` values. This allows operators to correlate
Expand Down
6 changes: 6 additions & 0 deletions gunicorn.conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@

from prometheus_client import multiprocess

from app.core.config import settings

bind = "0.0.0.0:8000"

workers = multiprocessing.cpu_count() * 2 + 1

worker_class = "uvicorn_worker.UvicornWorker"

# Fail closed: forwarded headers are ignored unless deployment explicitly lists
# the socket peers (reverse proxies) that are allowed to supply them.
forwarded_allow_ips = settings.FORWARDED_ALLOW_IPS

timeout = 120

accesslog = "-"
Expand Down
22 changes: 22 additions & 0 deletions src/app/core/client_ip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import ipaddress

from fastapi import Request


def resolve_client_ip(request: Request) -> str:
"""Return the canonical IP address already resolved by the ASGI server."""
if request.client is None:
return "unknown"

if len(request.client.host) > 45:
return "unknown"

try:
address = ipaddress.ip_address(request.client.host)
except ValueError:
return "unknown"

if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
return address.ipv4_mapped.compressed

return address.compressed
28 changes: 28 additions & 0 deletions src/app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ipaddress
from typing import Literal, Self
from urllib.parse import urlsplit

Expand Down Expand Up @@ -30,6 +31,8 @@ class Settings(BaseSettings):

CORS_ORIGINS: str = ""

FORWARDED_ALLOW_IPS: str = ""

REDIS_URL: SecretStr = SecretStr("")

REDIS_CONNECT_TIMEOUT_SECONDS: float = Field(default=1.0, gt=0, le=30)
Expand Down Expand Up @@ -156,6 +159,31 @@ class Settings(BaseSettings):

@model_validator(mode="after")
def validate_production_settings(self) -> Self:
trusted_proxies = self.FORWARDED_ALLOW_IPS.split(",")
if self.FORWARDED_ALLOW_IPS and any(
not value.strip() for value in trusted_proxies
):
raise ValueError("FORWARDED_ALLOW_IPS must not contain empty entries")

for trusted_proxy in trusted_proxies:
trusted_proxy = trusted_proxy.strip()
if not trusted_proxy:
continue

if trusted_proxy == "*":
raise ValueError("FORWARDED_ALLOW_IPS must not trust every client")

try:
if "/" in trusted_proxy:
ipaddress.ip_network(trusted_proxy, strict=True)
else:
ipaddress.ip_address(trusted_proxy)
except ValueError as exc:
raise ValueError(
"FORWARDED_ALLOW_IPS must contain only valid IP addresses "
"or canonical CIDR networks"
) from exc

if self.RATE_LIMIT_BACKEND == "redis" or self.OIDC_CACHE_BACKEND == "redis":
redis_url_value = self.REDIS_URL.get_secret_value()
redis_url = urlsplit(redis_url_value)
Expand Down
1 change: 1 addition & 0 deletions src/app/core/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def format(self, record: logging.LogRecord) -> str:

for field in (
"method",
"client_ip",
"path",
"route",
"status_code",
Expand Down
3 changes: 2 additions & 1 deletion src/app/middlewares/rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from fastapi.responses import JSONResponse
from redis.exceptions import RedisError

from app.core.client_ip import resolve_client_ip
from app.core.config import settings
from app.core.metrics import (
RATE_LIMIT_BACKEND_ERRORS_TOTAL,
Expand Down Expand Up @@ -129,7 +130,7 @@ def _redis_rate_limiter() -> RedisRateLimiter:


def _client_address(request: Request) -> str:
return request.client.host if request.client else "unknown"
return resolve_client_ip(request)


def setup_rate_limit(app: FastAPI) -> None:
Expand Down
2 changes: 2 additions & 0 deletions src/app/middlewares/request_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from fastapi import FastAPI, Request

from app.core.client_ip import resolve_client_ip
from app.core.metrics import (
HTTP_REQUEST_DURATION_SECONDS,
HTTP_REQUESTS_IN_PROGRESS,
Expand Down Expand Up @@ -70,6 +71,7 @@ async def request_logging_middleware(
"http_request",
extra={
"method": method,
"client_ip": resolve_client_ip(request),
"path": request.url.path,
"route": route,
"status_code": status_code,
Expand Down
79 changes: 79 additions & 0 deletions tests/test_client_ip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware

from app.core.client_ip import resolve_client_ip


def _request(client_host: str | None) -> Request:
client = (client_host, 12345) if client_host is not None else None
return Request(
{
"type": "http",
"method": "GET",
"path": "/",
"headers": [],
"client": client,
"server": ("testserver", 80),
"scheme": "http",
"query_string": b"",
}
)


def test_client_ip_is_canonicalized():
assert resolve_client_ip(_request("2001:0db8:0:0:0:0:0:1")) == "2001:db8::1"
assert resolve_client_ip(_request("::ffff:192.0.2.10")) == "192.0.2.10"


def test_missing_or_non_ip_client_fails_to_one_bounded_identity():
assert resolve_client_ip(_request(None)) == "unknown"
assert resolve_client_ip(_request("attacker-controlled-value")) == "unknown"
assert resolve_client_ip(_request("1" * 1024)) == "unknown"


def _proxy_test_client(*, socket_peer: str, trusted_hosts: str) -> TestClient:
inner_app = FastAPI()

@inner_app.get("/")
def client_address(request: Request):
return {"client_ip": resolve_client_ip(request)}

app = ProxyHeadersMiddleware(inner_app, trusted_hosts=trusted_hosts)
return TestClient(app, client=(socket_peer, 12345))


def test_untrusted_socket_peer_cannot_spoof_forwarded_client_ip():
client = _proxy_test_client(
socket_peer="192.0.2.10",
trusted_hosts="10.0.0.0/8",
)

response = client.get("/", headers={"X-Forwarded-For": "203.0.113.99"})

assert response.json() == {"client_ip": "192.0.2.10"}


def test_trusted_proxy_chain_uses_nearest_untrusted_address():
client = _proxy_test_client(
socket_peer="10.0.0.3",
trusted_hosts="10.0.0.0/8",
)

response = client.get(
"/",
headers={"X-Forwarded-For": "203.0.113.99, 198.51.100.20, 10.0.0.2"},
)

assert response.json() == {"client_ip": "198.51.100.20"}


def test_malformed_forwarded_address_fails_to_unknown_identity():
client = _proxy_test_client(
socket_peer="10.0.0.3",
trusted_hosts="10.0.0.0/8",
)

response = client.get("/", headers={"X-Forwarded-For": "not-an-ip"})

assert response.json() == {"client_ip": "unknown"}
36 changes: 36 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import runpy
from pathlib import Path

import pytest
from cryptography.fernet import Fernet

Expand Down Expand Up @@ -213,6 +216,39 @@ def test_rate_limit_numeric_settings_are_bounded():
)


def test_trusted_proxy_allowlist_accepts_addresses_and_canonical_networks():
configured = Settings(
DATABASE_URL="sqlite:///test.db",
SECRET_KEY="local-test-secret",
FORWARDED_ALLOW_IPS="127.0.0.1,10.0.0.0/8,2001:db8::/32",
_env_file=None,
)

assert configured.FORWARDED_ALLOW_IPS == "127.0.0.1,10.0.0.0/8,2001:db8::/32"


@pytest.mark.parametrize(
"allowlist",
("*", "not-an-address", "10.1.2.3/8", "127.0.0.1,"),
)
def test_trusted_proxy_allowlist_rejects_unsafe_values(allowlist):
with pytest.raises(ValueError, match="FORWARDED_ALLOW_IPS"):
Settings(
DATABASE_URL="sqlite:///test.db",
SECRET_KEY="local-test-secret",
FORWARDED_ALLOW_IPS=allowlist,
_env_file=None,
)


def test_gunicorn_uses_the_validated_trusted_proxy_allowlist(monkeypatch):
monkeypatch.setattr(settings, "FORWARDED_ALLOW_IPS", "10.0.0.0/8")

config = runpy.run_path(str(Path(__file__).parents[1] / "gunicorn.conf.py"))

assert config["forwarded_allow_ips"] == "10.0.0.0/8"


def test_outbox_mode_requires_complete_secure_configuration():
with pytest.raises(ValueError, match="OUTBOX_ENCRYPTION_KEY"):
Settings(
Expand Down
2 changes: 2 additions & 0 deletions tests/test_observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def test_json_formatter_emits_correlation_fields():
)
record.request_id = "request-123"
record.method = "GET"
record.client_ip = "192.0.2.10"
record.route = "/health/live"
record.status_code = 200
record.duration_ms = 1.25
Expand All @@ -86,6 +87,7 @@ def test_json_formatter_emits_correlation_fields():
assert payload["message"] == "http_request"
assert payload["request_id"] == "request-123"
assert payload["method"] == "GET"
assert payload["client_ip"] == "192.0.2.10"
assert payload["route"] == "/health/live"
assert payload["status_code"] == 200
assert payload["duration_ms"] == 1.25
Expand Down
2 changes: 1 addition & 1 deletion tests/test_rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def test_redis_key_is_versioned_bounded_and_privacy_preserving():
assert RedisRateLimiter.normalize_client("2001:0db8::1") == "2001:db8::1"


def test_forwarded_headers_do_not_change_the_asgi_client_address():
def test_forwarded_headers_are_not_parsed_by_the_rate_limiter():
request = Request(
{
"type": "http",
Expand Down
Loading