Trusted Proxy / Client IP
CratestackContext::client_ip() feeds the actor field on audit log
rows and is available to policies and procedures. By default it’s the
verified TCP socket peer — the address the OS’s own handshake produced,
never a client-suppliable value.
Behind a reverse proxy (nginx, an AWS ALB, a CDN), that socket peer is the
proxy’s own address, not the real client’s. The proxy forwards the real
address in a header — X-Forwarded-For or RFC 7239 Forwarded — but a
header is just bytes on the wire. Anyone who can reach your service
directly can set the same header to anything they want. Honoring it
unconditionally means an attacker can put whatever client_ip they like
into your audit trail.
TrustedProxyConfig closes that gap: forwarded headers are honored only
from peers you explicitly allowlist, and only as many hops deep as you
configure.
Safe default
Unconfigured, nothing changes:client_ip is the verified socket peer if
one is available, or absent entirely if it isn’t. Forwarded /
X-Forwarded-For are never consulted unless you apply a
TrustedProxyConfig. Nothing is ever guessed.
Configuring the allowlist
- Allowlist entries are
ipnet::IpNet— exact addresses (viaIpAddr’sInto<IpNet>, a/32or/128) or CIDR ranges, so you can trust a whole subnet (a load balancer’s private range) or a single known peer (a CDN edge IP). max_hops(default1) is how many entries deep into the chain to trust.1means “trust the immediate proxy’s own contribution to the chain”;2means “trust two proxies deep” (e.g. CDN in front of a load balancer, both configured to append their own hop).forwarded_headerselects which single header your proxy writes. Default isForwardedHeader::XForwardedFor— what nginx, an AWS ALB, and HAProxy’s defaults all actually send. Set it toForwardedHeader::Forwardedonly if your proxy is specifically configured to emit RFC 7239Forwardedinstead.
Only one header is ever consulted
Forwarded and X-Forwarded-For are alternatives, not complements — a
real proxy is configured to emit one or the other, never both
meaningfully. TrustedProxyConfig picks exactly one and never falls back
to the other. This is deliberate, not an oversight: an earlier
implementation checked Forwarded first whenever it was present at all,
falling through to X-Forwarded-For only when Forwarded was absent.
Since real proxies only ever write X-Forwarded-For, any Forwarded
header reaching the origin was entirely attacker-authored — and it was
being trusted outright, with no hop-count or allowlist check applied to
it at all. Naming the header your proxy actually writes closes that
bypass: the header you didn’t select is never even inspected, regardless
of who sends it or what it contains.
Hop counting is right-to-left
max_hops counts inward from the right end of the header’s
comma-separated chain — the end nearest your trusted proxy — not from the
left.
This matters because the left end of the chain is exactly the part an
untrusted client controls. A client can prepend as many fake entries as
it wants before the real chain starts; only the entries appended by
proxies you actually trust, on the right, are meaningful. Walking from
the left re-opens the same spoofing gap for any chain longer than one
hop — an attacker choosing client_ip just by sending a longer header.
max_hops(1), the rightmost entry — the immediate trusted proxy’s own
contribution — is taken: 192.0.2.55 above. With two trusted hops (a CDN
in front of a load balancer, both configured to append) and
max_hops(2), the second-from-right entry is taken: 203.0.113.9, what
the CDN reported seeing. max_hops deeper than the actual chain length,
or 0, yields no client_ip from the header rather than guessing.
The header is only consulted at all once the request’s verified socket
peer — never the header itself — is confirmed to be in the allowlist.
An untrusted peer’s forwarded headers are ignored outright, chain shape
notwithstanding.
Bootstrap: two required steps
ApplyingTrustedProxyConfig alone does nothing. The feature needs
both of the following, and omitting either one silently leaves
client_ip at its unconfigured-default behavior with no error, warning
at the API level, or other visible signal at request time:
client_ip is just silently None
(or the plain socket peer, if a load balancer terminates TCP directly
against your service) on every request, forever, with no per-request
error. Skipping it defeats the feature as completely as never applying
TrustedProxyConfig at all, just less visibly — the config looks
wired in a code review that only reads the .layer(...) call.
CrateStack logs a tracing::warn! once per process (not per request) if
a TrustedProxyConfig is present but a request arrives with no
ConnectInfo peer — that’s the closest thing to an in-process signal
this misconfiguration gets, and it only fires after step 2 is still
missing at request time, not at boot.
This section previously documented a second router that a transport grpc schema built via into_router(), needing the same two steps wired
onto it separately. Protobuf/gRPC support was removed in 0.8.5
(ADR 0017),
so there is only one router to protect: the one router() returns.
What this does not fix: #416’s rate-limit / idempotency fingerprint
Rate limiting and idempotency’s default principal fingerprints deliberately do not read forwarded headers — they hashAuthorization when present, and otherwise fall
back to ConnectInfo<SocketAddr> (the same verified socket peer this
page’s feature also relies on). That’s intentional: if the fingerprint
consulted X-Forwarded-For, a caller could pick their own rate-limit
bucket or idempotency key namespace just by setting the header, with no
proxy or allowlist involved at all — the exact bypass a fingerprint is
supposed to prevent.
But the practical effect is the same shape as this page’s headline
warning: unless the router is served through
into_make_service_with_connect_info, ConnectInfo is never present,
and every unauthenticated caller collapses onto a single shared
"anonymous" bucket — no distinct rate-limit budget or idempotency
namespace per caller, regardless of how many distinct clients are
actually calling in. Wiring into_make_service_with_connect_info (this
page’s step 2) is necessary for both features to distinguish callers by
IP, but a TrustedProxyConfig allowlist does not extend to them —
configuring trusted proxies for client_ip does not, by itself, give
rate limiting or idempotency per-client granularity for unauthenticated
traffic. Services that need that either authenticate before those
layers run, or supply an explicit with_key_fn / with_principal_fingerprint
callback.
Read Next
- Audit log — where
client_ipis recorded, in theactorfield - Rate limiting — the key function
ConnectInfoalso feeds, and why it doesn’t read forwarded headers - Idempotency — same principal-scoping caveat as rate limiting