Skip to content

v1.16.0 — Trusted-proxy client IP resolution

Choose a tag to compare

@cport1 cport1 released this 27 Jul 00:14
· 109 commits to main since this release

Security fix

X-Forwarded-For and X-Real-IP were honoured unconditionally by all three servers. Any caller reaching the server directly could set them to a clean residential IP and skip every IP-derived check — datacenter ranges, Tor/VPN, rate limiting, token IP binding, and PoW difficulty scaling.

These headers are now read only when the socket peer is a configured proxy.

TRUSTED_PROXIES

New environment variable: a comma-separated list of CIDRs and bare IPs.

Value Meaning
unset Loopback plus private/link-local ranges (the default)
10.0.0.0/8,203.0.113.7 Only these peers
* Every peer — only when an edge you control always overwrites the headers
none No peer; always use the socket address

The default covers nginx-on-localhost, sidecar proxies and in-cluster ingresses with no configuration, while a directly exposed server sees public peers and ignores their headers.

Upgrading: if your reverse proxy reaches FCaptcha from outside those ranges — a Cloudflare Tunnel, a load balancer in another VPC, or a PaaS edge (Railway's is in the RFC 6598 CGNAT block, 100.64.0.0/10) — set TRUSTED_PROXIES explicitly. Otherwise every visitor is attributed to the proxy, which quietly ruins rate limiting and IP reputation. The server logs a one-time warning naming the address to add, so check your logs after upgrading.

Also in this release

  • X-Forwarded-For is walked right-to-left, skipping trusted hops, instead of taking the leftmost entry. A client can prepend to the chain even behind a legitimate proxy, so the leftmost value was never the client's.
  • X-JA3-Hash and TRUSTED_JA4_HEADERS are gated on the same check. That allowlist covered header names, which says nothing about who set them — a client could send cf-ja4 itself and present a clean fingerprint.
  • Removed chi's middleware.RealIP from the Go server. It rewrote r.RemoteAddr from the same headers for every caller, leaving no un-forged source to fall back on.
  • Address normalisation (port stripped, IPv4-mapped IPv6 un-mapped). The Go server was passing RemoteAddr with its port to IsDatacenterIP on the no-header path, so that check silently never matched.
  • Misconfiguration warning, capped at 10 occurrences, naming the peer to add.

Tests

New unit suites in all three languages (clientip_test.go, clientip.test.js, test_clientip.py) covering spoof rejection, chain walking, address normalisation, spec parsing and the warning cap. test/test-detection.js remains at 86/86.