feat(api): trusted_proxies + trust_forwarded_for for real-client-IP resolution#4534
Conversation
Adds two top-level KernelConfig fields that let proxied deployments
(Cloudflare Tunnel, nginx, Traefik, …) recover the real client IP
from forwarding headers without being exploitable when no proxy is
present. Closes the long-standing TODO referenced in the now-retired
rate_limiter::resolve_client_ip doc comment.
trusted_proxies = ["172.19.0.0/16", "127.0.0.1", "::1"]
trust_forwarded_for = true
When BOTH are set AND the TCP peer matches the allowlist, the daemon
resolves the real client IP from forwarding headers (preference:
CF-Connecting-IP → X-Real-IP → Forwarded (RFC 7239) → rightmost-
untrusted hop in X-Forwarded-For) for:
* the GCRA rate limiter (per-IP keying)
* the auth-login rate limiter (per-IP keying)
* the per-IP WebSocket connection cap + the WS connect log line
Fail-closed by default — empty allowlist OR master-switch off OR peer
not in allowlist OR malformed headers all collapse back to the TCP
peer. A spoofed X-Forwarded-For from an untrusted internet client
still hits the limiter on its real source, so the per-IP brute-force
properties are preserved.
Implementation
* New crates/librefang-api/src/client_ip.rs with TrustedProxies
(hand-rolled CIDR matcher, no new deps), resolve_real_client_ip,
resolve_from_request.
* GcraState + new AuthRateLimitState carry Arc<TrustedProxies> +
trust_forwarded_for; server.rs compiles the allowlist once at
boot and threads it through both middleware layers.
* ws.rs::agent_ws uses the resolved IP for the WS slot key and the
client_ip log field.
* Old fail-closed-only rate_limiter::resolve_client_ip removed.
* Auth-bypass loopback gates (no-auth-allow-loopback,
LIBREFANG_ALLOW_NO_AUTH) intentionally still key on the TCP peer
via addr.ip(), not the resolved IP — auth bypass must never
follow a forwarded-header claim.
Tests
* 19 unit tests in client_ip: CIDR v4/v6, bare IPs, unmasked input,
invalid-entry skip, all four header preferences, RFC 7239
bracketed v6 + obfuscated _token, multi-header XFF concatenation,
v4:port suffix, malformed-fallback, all-trusted chain.
* 3 integration tests in rate_limiter:
- browsers behind a trusted proxy get independent buckets
- rotating XFF from an untrusted peer does not bypass the limiter
- master switch off ignores XFF even from a trusted peer
* Existing 611 librefang-api lib + 271 integration tests still
pass; workspace cargo clippy --all-targets -D warnings clean.
ReviewOverall: changes requested. The core design (allowlist + master switch, fail-closed by default, untrusted-peer headers ignored) is sound, the test coverage on Findings
What I liked
|
Threads `Arc<TrustedProxies>` + `trust_forwarded_for` through `AppState`,
compiled once at boot in `server.rs`. The GCRA + auth-login middlewares
now read from the cached instance instead of recompiling, and `ws::agent_ws`
no longer re-parses the raw config strings (and re-emits the malformed-entry
warning) on every WebSocket upgrade.
Also adds explicit "Behaviour change" comments at the two `agent_ws` sites
that propagate the resolved client IP into `SenderContext.user_id`, so a
future reader / operator flipping the new flags on understands that any
per-`user_id` kernel state (audit attribution, channel-sender keying,
session continuity that keys on it) re-keys from proxy IP to real client
IP at that moment. Pure documentation — no behaviour change vs the prior
PR commit.
Updates the test fixtures (`librefang-testing::test_app::build_state` and
the three in-tree `AppState` init sites in `routes/{agents,memory,network}.rs`)
to set the new fields to default (header trust off), matching the
production default.
Addresses review #2 (no per-upgrade recompilation), #5 (migration-notes
comments), and #10 (drops the cosmetic `drop(proxy_cfg)` since the
allowlist is now read straight off `AppState`).
`terminal_ws` was still keying its per-IP WS slot on `addr.ip()`, which is the same bug `agent_ws` was just fixed for: behind a trusted reverse proxy (cloudflared / nginx / Traefik), the TCP peer is the proxy and every terminal connection from every browser collapses onto a single shared slot — `max_ws_per_ip` then throttles the whole organisation the moment the second tab opens. Mirrors the `agent_ws` fix exactly: pulls the boot-compiled `Arc<TrustedProxies>` and `trust_forwarded_for` flag off `AppState` and calls `client_ip::resolve_real_client_ip` before `try_acquire_ws_slot`. Untrusted peers fall through to `addr.ip()` — a spoofed `X-Forwarded-For` from the open internet still hits the per-IP cap on its real source. `SenderContext.user_id` parity is N/A here: `terminal_ws` does not construct a `SenderContext` (the terminal is a PTY pipe, not a kernel agent message), so the only behavioural surface that needs to swap to the resolved IP is the WS slot key + the rejection log line. Addresses review #1.
Five header-parsing fixes lifted from review:
- **XFF: bracketed IPv6 with port** — `[2001:db8::1]:1234` fell off the
fallback parser because `parse::<IpAddr>` rejects the bracket form
and the prior `rsplit_once(':')` left the brackets attached. The
walker now strips a surrounding `[...]` (with or without `:port`)
before retry, while still bailing on ambiguous unbracketed v6 + port
rather than silently truncating.
- **RFC 7239 `for=` parameter case-insensitive** — RFC 7230 §3.2.4
makes parameter names case-insensitive; we were only matching `for=`
and `For=`. Now compares the key with `eq_ignore_ascii_case("for")`.
- **RFC 7239 `unknown` token case-insensitive** — companion fix; now
compares with `eq_ignore_ascii_case("unknown")` so `Unknown` /
`UNKNOWN` short-circuit the same way as the lowercase form.
- **Single-value headers reject the unspecified address** — a
misconfigured proxy that injects `0.0.0.0` / `::` into
`cf-connecting-ip` / `x-real-ip` no longer poisons the per-IP slot
key with an address that can't be a real client. Loopback and
RFC1918/ULA addresses are accepted by design — the trusted proxy
may legitimately pass an internal-network client. Doc-comment
spells out the policy.
- **Documentation comments** for the asymmetries reviewers flagged:
`single_ip_header` reads only the first instance of the named
header (vs the XFF path which concatenates all); the `Forwarded`
parser only inspects the first list element and obfuscated tokens
short-circuit by design.
Adds 8 new unit tests covering: bracketed-v6 with/without port,
case-insensitive `for=` (4 variants), case-insensitive `unknown`
(3 variants), unspecified-address rejection on both single-value
headers (4 variants), loopback + private acceptance (5 variants),
plus two WS-slot-key composition tests asserting the resolver
collapses spoof attempts from an untrusted peer down to the real
TCP source AND separates real clients behind the same trusted proxy
into distinct slot keys.
Addresses review #3, #4, #6, #7, #8, #9.
|
@houko thanks — addressed everything. Pushed three commits on top of the
Test results: Side-note on the |
|
Re-review after Spot-checks of the resolution patches:
The 27 unit tests + 3 integration tests give strong coverage; One new observation
Nits — no action needed
Re-confirmed clean
Nice tightening on this round. LGTM modulo the hot-reload note. Happy to approve once CI clears. |
Summary
Adds two top-level
KernelConfigfields that let proxied deployments (Cloudflare Tunnel, nginx, Traefik, …) recover the real client IP from forwarding headers — without being exploitable when no proxy is present. Closes the long-standing TODO referenced in the now-retiredrate_limiter::resolve_client_ipdoc comment, which deferred header trust pending exactly this allowlist.When both are set and the TCP peer matches the allowlist, the daemon resolves the real client IP from forwarding headers (preference:
CF-Connecting-IP→X-Real-IP→Forwarded(RFC 7239) → rightmost-untrusted hop inX-Forwarded-For) for:agent_wsandterminal_ws)SenderContext.user_id(see Migration notes below)Why
Today, librefang behind a reverse proxy (cloudflared, nginx, Traefik) sees every request originate from the proxy's address. Symptoms:
client_ip=<proxy>for every browserThe existing
detect_connection_localityhelper inws.rsparsed the forwarding headers but only used the result for a debug log — it never substituted the real IP for keying. Header trust was previously documented as deliberately removed (see theresolve_client_ipdoc comment) because trusting headers without a verified upstream is exploitable: any internet client could rotateX-Forwarded-Forper request and defeat the per-IP limiter (the counter never advances past 1 for any unique forged IP).This PR is the gating mechanism that comment was waiting for.
How it stays safe
Fail-closed by default. Either flag missing → behaviour unchanged. Specifically:
trust_forwarded_for = false(default) → headers ignored, TCP peer used.trusted_proxies = [](default) → headers ignored regardless of master switch.trusted_proxies→ headers ignored. A spoofedX-Forwarded-Forfrom the open internet still hits the limiter on its real source.X-Forwarded-Forwalked right-to-left, dropping hops that matchtrusted_proxiesand stopping at the first non-matching value — a malicious leftmost hop cannot poison the result.CF-Connecting-IP,X-Real-IP) reject the unspecified address (0.0.0.0,::); a misconfigured proxy emitting those values cannot poison the slot key with an address that can't be a real client. Loopback / private addresses are accepted by design — the trusted proxy may legitimately pass an internal-network client.Forwardedfor=parameter and theunknowntoken are matched case-insensitively per RFC 7230 §3.2.4.Auth-bypass loopback gates intentionally still key on
addr.ip(), not the resolved IP —LIBREFANG_ALLOW_NO_AUTHand the no-auth + loopback auto-allow path must never follow a forwarded-header claim. Only rate-limiter keying and the WS slot key adopt the resolved IP.Implementation
crates/librefang-api/src/client_ip.rswith:TrustedProxies::compile(&[String])— hand-rolled CIDR matcher (no new deps), accepts CIDRs ("172.19.0.0/16","2001:db8::/32") or bare IPs ("127.0.0.1","::1"); invalid entries are warn-logged and skipped so a single typo can't take down boot.resolve_real_client_ip(peer, headers, trusted, trust_flag)— gated header parsing.resolve_from_request(req, trusted, trust_flag)— convenience wrapper for axum middleware.Arc<TrustedProxies>+trust_forwarded_forflag now live onAppState, built once at boot inserver.rsand shared with every consumer:GcraStateandAuthRateLimitStatemiddlewaresws::agent_ws(per-IP WS slot +client_iplog +SenderContext.user_id)routes::terminal::terminal_ws(per-IP WS slot + log)ws.rs::agent_wsuses the resolved IP for both the WS slot key and theclient_iplog field.routes::terminal::terminal_wsmirrors that — was previously keying onaddr.ip(), which collapsed every terminal behind cloudflared onto one shared slot.rate_limiter::resolve_client_ipremoved; module-level note points at the new gating.Test plan
client_ip: CIDR v4/v6, bare IPs, unmasked input (e.g.10.0.0.5/8→ still matches10.0.0.0/8), invalid-entry skip, all four header preferences, RFC 7239 bracketed v6 + obfuscated_tokenform, multi-header XFF concatenation, v4:port suffix, bracketed[v6]:portand bare[v6]in XFF, case-insensitivefor=parameter (4 variants), case-insensitiveunknowntoken (3 variants), single-value-header unspecified-address rejection (4 variants) + loopback/private acceptance (5 variants), malformed-fallback, all-trusted chain, WS-slot-key spoof regression (untrusted peer rotating XFF collapses to one key) + WS-slot-key separation (two browsers behind the same trusted proxy get distinct keys).rate_limiter:librefang-apilib tests (619) + integration tests still passcargo clippy --workspace --all-targets -- -D warningscleancargo fmt --checkcleanDocs
CHANGELOG.md[Unreleased]### Addedupdated.docs/src/app/configuration/core/page.mdxtop-level fields table extended (English).docs/src/app/zh/configuration/core/page.mdxChinese mirror translated — would appreciate a native-speaker review on the wording, since this is a security-sensitive feature and the nuance around "fail-closed" and "rightmost-untrusted hop" matters.Migration notes for operators
proxy_set_header X-Forwarded-For, etc.): settrusted_proxiesto the proxy's source CIDR (e.g. the docker bridge subnet, or127.0.0.1if the proxy is on the host) andtrust_forwarded_for = true. Restart.0.0.0.0:4545directly, an attacker can bypass the proxy entirely. The recommended hardening is to bindapi_listento loopback (already the new install default per Windows automatically initializes after installation #2766) and force public traffic through the trusted proxy.Behaviour change when both flags flip on
When operators opt in by setting both flags, the dashboard WebSocket
chat (
agent_ws) constructsSenderContext.user_idfrom the resolvedreal client IP instead of the proxy's TCP peer address. Any kernel-side
state that keys on
user_idtherefore re-keys at that moment:user_idswitch from the proxy IP to the real browser IP. New rows in the hash chain
carry the new keying — old rows are unaffected (the chain is append-only).
SenderContextplumbing the channel bridges use. Code paths that keyper-
user_idcontinuity off the dashboard chat will see the new IPs asfresh senders. The persistent dashboard session itself is unaffected
(it uses
use_canonical_session: true, which keys on the agent'scanonical session ID, not on
user_id).user_idrate limits / quotas (if you've added any operator-sidequotas keyed on it) similarly re-key.
terminal_wsdoes not construct aSenderContext, so the only behaviourchange there is the per-IP WS slot key + the connect/reject log line.
If your environment relies on
user_id == proxy_ipfor any operationalprocess (alerting, dashboard rules, log filters), update those rules
before enabling the flags.