Client IP
Behind a reverse proxy, the address your app sees is the proxy's. The real
client is in X-Forwarded-For, and reading that header naively is one of
the most reliably exploited mistakes in web software.
X-Forwarded-For is append-only. Its leftmost entry is whatever the
original caller wrote, so an attacker sets it to anything. Only the
entries your own proxies appended can be believed.
from fastapi import FastAPI, Request
from grelmicro.clientip import TrustedProxies, resolve_client_address
app = FastAPI()
# Your own proxies. Required, and there is no wildcard.
trusted = TrustedProxies(["10.0.0.0/8"])
@app.get("/whoami")
async def whoami(request: Request) -> dict[str, str]:
client = resolve_client_address(request.scope, trusted)
if client is None:
return {"client": "unknown"}
# `key` is safe to store or to use as a rate limiter bucket.
return {"client": client.key, "reason": client.reason}
Turn off your server's own proxy handling first
Uvicorn rewrites scope["client"] from X-Forwarded-For by default,
before any application middleware runs. If you leave it on, the address
this module treats as the verified peer is itself header-derived, and
every guarantee below is void: a caller who writes
X-Forwarded-For: 6.6.6.6, 10.0.0.1 gets 6.6.6.6 back as RESOLVED.
Run uvicorn with --no-proxy-headers, or Config(..., proxy_headers=False).
Hypercorn and granian only apply theirs when you wrap the app, so leave
those wrappers off.
TrustedProxies takes your proxies' addresses. It is required, and there
is no wildcard. To trust every peer, pass ["0.0.0.0/0", "::/0"], which
someone reviewing a config will notice.
What it guarantees
ClientAddress.ip is always an address the request actually came
from, or one a verified trusted proxy vouched for. It is never text from
the header, so it is safe as a rate limiter key or an audit record.
The resolution reads the header only when the connecting peer is itself a trusted proxy. Otherwise anyone who can reach the app directly forges the whole chain.
Why it matters for a rate limiter
Keying on a spoofable value means a caller sends a different
X-Forwarded-For per request and never hits the limit. It also means an
attacker can spoof your address and get you throttled.
async def api(request: Request) -> dict:
client = resolve_client_address(request.scope, trusted)
await limiter.acquire_or_raise(key=client.key if client else "unknown")
...
Use .key rather than str(client.ip). It folds an IPv4-mapped address
onto its IPv4 form, so one caller cannot occupy two buckets by connecting
over a dual-stack socket.
Reading the outcome
Every result carries a reason. Only RESOLVED means a trusted proxy
vouched for the address, and everything else means the header could not be
believed, so the verified peer was used instead.
| Reason | What happened |
|---|---|
RESOLVED |
A trusted proxy vouched for this address. |
NO_FORWARDED_HEADER |
Trusted peer, no header. The peer is the client. |
UNTRUSTED_PEER |
The connecting peer is not a trusted proxy, so the header was ignored. |
CHAIN_EXHAUSTED |
Every entry was a trusted proxy. |
MALFORMED_ENTRY |
An entry was not a valid address. |
HOP_LIMIT, TOO_MANY_ENTRIES, HEADER_TOO_LARGE |
A bound was hit. |
A rate limiter can use .key whatever the reason, because every value is
an address the caller cannot choose. Anything that treats the address as
the caller's identity must check degraded first:
if client is None or client.degraded:
return False # the address is the proxy's, not the caller's
degraded is True whenever the chain could not be believed, so ip holds
the connecting peer. Behind a proxy that peer is the proxy, whose address
is private, which is how an allowlist accidentally admits everyone.
An audit log should record the reason. A rising CHAIN_EXHAUSTED or
MALFORMED_ENTRY rate is worth an alert, since both mean something is
sending chains your topology does not explain.
A missing trusted set is not a safe default
resolve_client_address(scope) does not exist. The trusted set is a
required argument, because the correct value depends on your topology
and no library can guess it. An empty TrustedProxies([]) is legal
and means trust nothing, so the peer is always returned.
One value, read everywhere
Two subsystems resolving the client separately will drift, and one will
end up recording the proxy. ClientAddressMiddleware resolves once and
caches the result on the request:
from grelmicro.clientip import ClientAddressMiddleware, TrustedProxies
app.add_middleware(
ClientAddressMiddleware, trusted=TrustedProxies(["10.0.0.0/8"])
)
Handlers then read getattr(request.state, "client_address", None),
which is None when the peer could not be resolved at all. It is pure ASGI,
so it works on any ASGI server.
overwrite_scope_client=True additionally rewrites scope["client"], for
code that reads request.client.host and cannot be changed. It is off by
default, because it discards the verified peer an audit record wants.
If you only run uvicorn
Uvicorn does something similar, controlled by --forwarded-allow-ips, and
it is on unless you disable it. If uvicorn is your only deployment
target, configuring it is a fine answer and you do not need this module.
It is not the same algorithm. Under --forwarded-allow-ips='*' uvicorn
returns the leftmost entry, it accepts values that are not addresses at
all, and it reports port 0.
Reach for this when you want the trusted set required rather than defaulted, the verified peer kept alongside the resolved address, a reason you can alert on, or one behaviour across uvicorn, hypercorn and granian, which differ in both algorithm and trust model.
Behaviour worth knowing
Every entry trusted. Most implementations fall back to the leftmost
entry. This one returns the verified peer with CHAIN_EXHAUSTED, because
in that case no proxy vouched for the leftmost against an untrusted peer,
which is exactly the value that must not become a rate limiter key.
A malformed entry stops the walk. It is not skipped. Skipping lets an attacker insert padding to shift which entry gets chosen.
A zone id is dropped. fe80::1%eth0 keys as fe80::1, so two hosts
reached over different interfaces share one key. Link-local addresses are
rarely a client identity, but it is worth knowing.
Configuration fails loudly. TrustedProxies(["10.0.0.1/8"]) raises,
because it has host bits set. Some servers silently downgrade an entry
like that to a string literal that then matches nothing, leaving you with
an empty trusted set and no error.