Problem
All API handlers use `InsecureClientIp` from `axum-client-ip`. This extractor scans headers in a fixed order and takes the leftmost `X-Forwarded-For` value. The leftmost XFF value is client-controlled — any client can send `X-Forwarded-For: 1.2.3.4` and tuwunel will log, rate-limit, and feed security tooling against that forged address.
The library's own documentation describes `InsecureClientIp` as appropriate when you "prefer to sacrifice security for probably statistically better IP determination," citing geolocation as the intended use case. Rate limiting and security tooling are the explicit counter-examples.
Two concrete problems with the current scan order:
- Leftmost = client-controlled. A client sets `X-Forwarded-For: ` and wins.
- `CF-Connecting-IP` is checked last, after `X-Forwarded-For`. Cloudflare Tunnel deployments remain spoofable even though `CF-Connecting-IP` is trustworthy — a client can inject a forged XFF value that gets picked up first.
Affected deployments
Any tuwunel instance behind a reverse proxy — nginx, Caddy, Cloudflare Tunnel, Fly.io, AWS CloudFront. This is the recommended production topology per the docs.
Proposed solution
Add an optional `ip_source` config field. When absent, handlers continue using `InsecureClientIp` exactly as today — no operator action required, no behavior change. When set, a request middleware resolves the IP using `SecureClientIp` with the configured source and stores it in extensions; handlers read from a new `ClientIp` extractor backed by those extensions.
`SecureClientIp` reads from exactly one configured source and takes the rightmost value on multi-value headers. The rightmost value is appended by the proxy and cannot be overridden by the client. It is spoofing-resistant.
# Not set (default) — InsecureClientIp behavior unchanged, no migration required
# Direct connection; explicitly disable header scanning
ip_source = "connect_info"
# Behind nginx or Caddy
ip_source = "rightmost_x_forwarded_for"
# Behind Cloudflare Tunnel (cloudflared)
ip_source = "cf_connecting_ip"
Supported values map 1:1 to `SecureClientIpSource` variants already in the crate:
| Value |
Header |
Proxy |
connect_info |
TCP peer address only |
Direct, or to explicitly disable header scanning |
rightmost_x_forwarded_for |
X-Forwarded-For rightmost |
nginx, Caddy |
rightmost_forwarded |
Forwarded rightmost |
RFC 7239 proxies |
x_real_ip |
X-Real-IP |
nginx |
cf_connecting_ip |
CF-Connecting-IP |
Cloudflare, cloudflared |
true_client_ip |
True-Client-IP |
Cloudflare Enterprise, Akamai |
fly_client_ip |
Fly-Client-IP |
Fly.io |
cloudfront_viewer_address |
CloudFront-Viewer-Address |
AWS CloudFront |
ClientIp extractor design
The `ClientIp` wrapper extractor branches at request time:
- If `SecureClientIpSource` is present in extensions (i.e. `ip_source` is configured), delegate to `SecureClientIp` with that source.
- If not present, fall back to `InsecureClientIp` — preserving today's behavior exactly, including its header fallback chain.
This means Unix socket deployments are handled correctly: when `ip_source` is absent, the fallback to `InsecureClientIp`'s header scan remains in place, which matters because Unix sockets cannot provide `ConnectInfo` (see #310).
Note: `SecureClientIpSource::ConnectInfo` is already installed as an extension in `src/router/layers.rs` today (hardcoded). The infrastructure for `SecureClientIp` to resolve in handlers already exists — this proposal makes the source configurable rather than adding a new dependency.
Trade-offs
Security improvement is opt-in. Operators who do not set `ip_source` keep existing behavior. This is intentional — the alternative is breaking all proxy deployments on update.
`ip_source` is restart-required. The router layer stack is built once at startup and is not rebuilt on config reload. Hot-reloading `ip_source` will be rejected with an error.
Hard failure when configured header is absent. `SecureClientIp` returns an error rather than falling back to the next available source. An operator who sets `cf_connecting_ip` with a misconfigured proxy will see errors rather than a degraded fallback. A startup warning when `ip_source` is set to a header-based value can prompt operators to verify their proxy configuration.
Benefits once adopted
- CrowdSec HTTP scenarios and fail2ban see real client IPs behind all supported proxy topologies
- Rate limiting cannot be bypassed by header injection
- First-class support for Cloudflare Tunnel, Fly.io, and CloudFront — deployments where the correct header currently loses to a spoofable XFF in the scan order
Planned contributions
I intend to submit two PRs against this issue:
-
Handler migration — introduce the `ClientIp` extractor with the two-mode fallback logic described above, and migrate all handlers from `InsecureClientIp` to `ClientIp`. Default behavior is unchanged for all existing deployments.
-
Configurable `ip_source` — add the `ip_source` config field (typed enum, serde `rename_all = "snake_case"`), wire it into the layer stack, add a startup warning for header-based sources, and reject `ip_source` changes during hot reload. This PR builds on PR 1 and is where the spoofing protection becomes available to operators.
Problem
All API handlers use `InsecureClientIp` from `axum-client-ip`. This extractor scans headers in a fixed order and takes the leftmost `X-Forwarded-For` value. The leftmost XFF value is client-controlled — any client can send `X-Forwarded-For: 1.2.3.4` and tuwunel will log, rate-limit, and feed security tooling against that forged address.
The library's own documentation describes `InsecureClientIp` as appropriate when you "prefer to sacrifice security for probably statistically better IP determination," citing geolocation as the intended use case. Rate limiting and security tooling are the explicit counter-examples.
Two concrete problems with the current scan order:
Affected deployments
Any tuwunel instance behind a reverse proxy — nginx, Caddy, Cloudflare Tunnel, Fly.io, AWS CloudFront. This is the recommended production topology per the docs.
Proposed solution
Add an optional `ip_source` config field. When absent, handlers continue using `InsecureClientIp` exactly as today — no operator action required, no behavior change. When set, a request middleware resolves the IP using `SecureClientIp` with the configured source and stores it in extensions; handlers read from a new `ClientIp` extractor backed by those extensions.
`SecureClientIp` reads from exactly one configured source and takes the rightmost value on multi-value headers. The rightmost value is appended by the proxy and cannot be overridden by the client. It is spoofing-resistant.
Supported values map 1:1 to `SecureClientIpSource` variants already in the crate:
connect_inforightmost_x_forwarded_forX-Forwarded-Forrightmostrightmost_forwardedForwardedrightmostx_real_ipX-Real-IPcf_connecting_ipCF-Connecting-IPtrue_client_ipTrue-Client-IPfly_client_ipFly-Client-IPcloudfront_viewer_addressCloudFront-Viewer-AddressClientIp extractor design
The `ClientIp` wrapper extractor branches at request time:
This means Unix socket deployments are handled correctly: when `ip_source` is absent, the fallback to `InsecureClientIp`'s header scan remains in place, which matters because Unix sockets cannot provide `ConnectInfo` (see #310).
Note: `SecureClientIpSource::ConnectInfo` is already installed as an extension in `src/router/layers.rs` today (hardcoded). The infrastructure for `SecureClientIp` to resolve in handlers already exists — this proposal makes the source configurable rather than adding a new dependency.
Trade-offs
Security improvement is opt-in. Operators who do not set `ip_source` keep existing behavior. This is intentional — the alternative is breaking all proxy deployments on update.
`ip_source` is restart-required. The router layer stack is built once at startup and is not rebuilt on config reload. Hot-reloading `ip_source` will be rejected with an error.
Hard failure when configured header is absent. `SecureClientIp` returns an error rather than falling back to the next available source. An operator who sets `cf_connecting_ip` with a misconfigured proxy will see errors rather than a degraded fallback. A startup warning when `ip_source` is set to a header-based value can prompt operators to verify their proxy configuration.
Benefits once adopted
Planned contributions
I intend to submit two PRs against this issue:
Handler migration — introduce the `ClientIp` extractor with the two-mode fallback logic described above, and migrate all handlers from `InsecureClientIp` to `ClientIp`. Default behavior is unchanged for all existing deployments.
Configurable `ip_source` — add the `ip_source` config field (typed enum, serde `rename_all = "snake_case"`), wire it into the layer stack, add a startup warning for header-based sources, and reject `ip_source` changes during hot reload. This PR builds on PR 1 and is where the spoofing protection becomes available to operators.