docs(proxy): say TRUST_CF_CONNECTING_IP needs a Cloudflare-only origin

The flag makes the app believe CF-Connecting-IP from every connection,
before the peer check and whatever TRUST_PROXY_HOPS says, which is safe
only when nothing but Cloudflare can reach the origin. The reverse-proxy
page now says so with the ways to get there, the compose comment and both
example files carry the same condition, and the variable is in the env
manifest. The page's list of peers whose forwarding headers are read now
matches the code: loopback, 0.0.0.0/8, private, CGNAT, link-local,
unique-local and site-local, the reserved blocks and multicast. Those last
two stay trusted, since no internet host can connect from them; a test
pins every listed range and the Cloudflare precedence.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 16:00:35 +01:00
1 parent 8471e0edc9
commit 877db9a5b7
6 files changed
+114 -26

No files matched your search

+8 -2
View File
@@ -202,7 +202,10 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
#
# Set this to "1" ONLY when every public request to HealthLog terminates
# at Cloudflare (Tunnel, Workers, classic proxy) before it reaches your
# origin. Deployments behind nginx, Caddy, Traefik, an AWS ALB, or any
# origin, and the origin accepts connections from Cloudflare alone (a
# Tunnel, or a firewall that admits only Cloudflare's address ranges): the
# header is believed from every connection, before any other check.
# Deployments behind nginx, Caddy, Traefik, an AWS ALB, or any
# direct-to-Next.js exposure MUST leave this off.
#
# Security implications when the flag is mis-set:
@@ -224,7 +227,10 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# Number of trusted reverse-proxy hops in front of the app. The client IP is
# read that many entries from the right of the X-Forwarded-For chain. Set 0
# for a directly-exposed host; 1 (the default when unset) behind a single
# reverse proxy. X-Real-IP is read only at 1. A wrong value makes the app
# reverse proxy. X-Real-IP is read only at 1. Forwarding headers are read only
# from a connection whose own address is not public (loopback, private, CGNAT,
# unique-local, link-local, reserved or multicast; the full list is in
# docs/self-hosting/reverse-proxy.md). A wrong value makes the app
# refuse the headers, log one warning, and share one rate-limit bucket across
# all anonymous callers.
# TRUST_PROXY_HOPS="1"
+15 -10
View File
@@ -554,21 +554,26 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# -----------------------------------------------------------------------------
# Client-IP resolution -- optional (required behind Cloudflare)
# -----------------------------------------------------------------------------
# When the stack sits behind Cloudflare, the real visitor IP arrives in the
# `cf-connecting-ip` header; the X-Forwarded-For chain otherwise yields the
# Cloudflare edge IP, so the audit-log overview stores the CF datacentre
# address and the geo lookup resolves the wrong location. Set this to "1"
# ONLY when Cloudflare is in front -- a direct-exposed self-host must leave
# it unset, or an attacker could forge the header. Off by default.
# Number of trusted reverse-proxy hops in front of the app; the client IP is
# read that many entries from the right of the X-Forwarded-For chain. 0 for a
# directly-exposed host (the connection's own address is used), 1 (the
# default when unset) behind a single reverse proxy; X-Real-IP is read only
# at 1. Forwarding headers are never read from a connection that comes from a
# public address: only a proxy on loopback or a private network is believed. A wrong value makes the app refuse the
# headers, log one warning, and collapse anonymous callers into one
# rate-limit bucket.
# at 1. Forwarding headers are read only from a connection whose own address
# is not public: loopback, private, CGNAT, unique-local, link-local, reserved
# or multicast (the full list is in docs/self-hosting/reverse-proxy.md). A
# wrong value makes the app refuse the headers, log one warning, and collapse
# anonymous callers into one rate-limit bucket.
# TRUST_PROXY_HOPS="1"
#
# Behind Cloudflare the real visitor IP arrives in the `cf-connecting-ip`
# header; the X-Forwarded-For chain otherwise yields the Cloudflare edge IP,
# so the audit-log overview stores the CF datacentre address and the geo
# lookup resolves the wrong location. "1" believes that header from EVERY
# connection, before the checks above, so set it ONLY when the origin accepts
# connections from Cloudflare alone (a Cloudflare Tunnel, or a firewall that
# admits only Cloudflare's address ranges). An origin that also answers on
# its own public address lets any caller forge the address that rate limits
# and the audit log record. Off by default.
# TRUST_CF_CONNECTING_IP="1"
# Memory cap of the app container (read by docker-compose.yml, not by the app).
+4 -2
View File
@@ -92,8 +92,10 @@ x-healthlog-env: &healthlog_env
TELEGRAM_WEBHOOK_SECRET: "${TELEGRAM_WEBHOOK_SECRET:-}"
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
# the real visitor IP is in `cf-connecting-ip`; set
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
# default — a direct-exposed host must not trust a forgeable header).
# TRUST_CF_CONNECTING_IP=1 ONLY when the origin accepts connections from
# Cloudflare alone (a Tunnel, or a firewall admitting only Cloudflare's
# ranges): the header is believed from every connection (off by default;
# see docs/self-hosting/reverse-proxy.md).
# IP_GEO_LOOKUP_URL overrides the default ipwho.is provider (HTTPS by
# default); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress.
# IP_GEO_ALLOW_INSECURE=true permits a plain-HTTP provider URL (e.g. the
+39 -11
View File
@@ -16,17 +16,18 @@ tailnet with no extra container. See
## Pre-flight — env vars the proxy interacts with
| Variable | Required for | Notes |
| --------------------- | ---------------------------------------------- | ---------------------------------------- |
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
| Variable | Required for | Notes |
| ------------------------ | ---------------------------------------------- | ---------------------------------------- |
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
| `TRUST_CF_CONNECTING_IP` | Reading the visitor address Cloudflare sends | Off unless `1`; see the warning below |
Set the URL pair to the public origin (`https://your-instance.example.com`)
and restart the `app` container. The image reads both at startup; no
rebuild needed.
All three variables are on the shipped `docker-compose.yml`
All four variables are on the shipped `docker-compose.yml`
`environment:` whitelist, so a value in `.env` reaches the container
without editing the compose file. (Before v1.37.33 the whitelist was
missing `TRUST_PROXY_HOPS`; on those versions add
@@ -51,18 +52,45 @@ A misconfigured count logs a one-shot warning to stderr and collapses
every anonymous caller into one shared rate-limit bucket. Match the
value to the actual hop count.
Forwarding headers are believed only from a connection that could come
from a proxy: loopback, a private or unique-local network, or CGNAT (a
Tailscale address). A request that reaches HealthLog straight from a public
Forwarding headers are believed only from a connection whose own address
is not a public one, which is where a proxy you run connects from:
- IPv4: loopback (`127.0.0.0/8`), `0.0.0.0/8`, the private ranges
(`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), CGNAT
(`100.64.0.0/10`, Tailscale), link-local (`169.254.0.0/16`), the reserved
blocks `192.0.0.0/24` and `198.18.0.0/15`, and everything from
`224.0.0.0` up (multicast and reserved);
- IPv6: `::` and `::1`, link-local (`fe80::/10`), site-local (`fec0::/10`),
unique-local (`fc00::/7`, Tailscale), multicast (`ff00::/8`), and an
IPv4-mapped or embedded address from one of the IPv4 ranges above.
The reserved and multicast blocks are in the list because no host on the
internet can open a connection from them, so a peer there sits on a network
you run. A request that reaches HealthLog straight from a public
address carries headers its sender wrote, so HealthLog ignores them and
uses the connection's own address, and says so once in the log. A proxy on
the same host or Docker network connects from a private address, so the
usual setups above need nothing. If your proxy reaches HealthLog over a
public address, put it on a private network with HealthLog instead (or, for
Cloudflare straight to the origin, use `TRUST_CF_CONNECTING_IP=1`). The log
public address, put it on a private network with HealthLog instead. The log
line also shows up when the published port is open to the internet beside
the proxy; bind it to the proxy's network or to `127.0.0.1` then.
### `TRUST_CF_CONNECTING_IP` — only when the origin takes Cloudflare only
With `TRUST_CF_CONNECTING_IP=1` HealthLog takes the visitor's address from
the `CF-Connecting-IP` header, before any of the checks above: from every
connection, whatever its address, and whatever `TRUST_PROXY_HOPS` says.
Cloudflare sets that header on every request through its edge, but any
client can send it too. So the flag is safe only when nothing but
Cloudflare can reach the origin: a Cloudflare Tunnel with no published
port, or a firewall that admits only
[Cloudflare's address ranges](https://www.cloudflare.com/ips/) to the port
HealthLog or its proxy listens on (Authenticated Origin Pulls on top is
better still). If the origin also answers on its own public address, a
caller who finds it chooses the address every per-IP rate limit, the audit
log and the sign-in locations record, and changes it on every request.
Without Cloudflare in front, leave the flag unset.
`X-Real-IP` gets the same trust as `X-Forwarded-For`. It is read only
when `TRUST_PROXY_HOPS` is exactly `1` and the request carries no
`X-Forwarded-For`, which covers a single proxy that sets only
+6 -1
View File
@@ -424,7 +424,12 @@
},
{
"name": "TRUST_PROXY_HOPS",
"purpose": "Trusted reverse-proxy hop count; the client IP is read that many entries from the right of X-Forwarded-For.",
"purpose": "Trusted reverse-proxy hop count; the client IP is read that many entries from the right of X-Forwarded-For. Forwarding headers are read only from a peer whose own address is not public (loopback, private, CGNAT, unique-local, link-local, reserved, multicast).",
"example": "1"
},
{
"name": "TRUST_CF_CONNECTING_IP",
"purpose": "\"1\" takes the client IP from Cloudflare's CF-Connecting-IP header, from every connection and before the peer check. Safe only when the origin accepts connections from Cloudflare alone (a Tunnel, or a firewall admitting only Cloudflare's ranges); otherwise any caller forges the address rate limits and the audit log record. Off by default.",
"example": "1"
},
{
+42
View File
@@ -407,6 +407,48 @@ describe("getClientIp against the connection's own peer", () => {
).toBe("203.0.113.6");
});
// The ranges docs/self-hosting/reverse-proxy.md lists, one address each.
// Reserved and multicast stay in: no host on the internet can complete a
// TCP handshake from them, so a peer there is on a network the operator
// runs, like a private one.
it("reads forwarding headers from every range the reverse-proxy page lists", () => {
for (const proxy of [
"127.0.0.2", // loopback
"0.0.0.1", // 0.0.0.0/8
"10.1.2.3", // private
"172.31.0.9",
"192.168.1.20",
"100.64.0.1", // CGNAT (Tailscale)
"169.254.10.10", // link-local
"192.0.0.8", // IETF protocol assignments
"198.18.0.4", // benchmarking
"240.0.0.1", // reserved
"fd7a:115c:a1e0::1", // unique-local (Tailscale)
"fe80::1", // link-local
"fec0::1", // site-local
"::ffff:10.0.0.3", // IPv4-mapped private
]) {
expect(
getClientIp(stamped(proxy, { "x-forwarded-for": "203.0.113.7" })),
proxy,
).toBe("203.0.113.7");
}
for (const outside of ["8.8.8.8", "2001:4860:4860::8888"]) {
expect(
getClientIp(stamped(outside, { "x-forwarded-for": "203.0.113.7" })),
).toBe(outside);
}
});
it("believes cf-connecting-ip from any peer once the flag is on, which is why the origin must take Cloudflare only", () => {
process.env.TRUST_CF_CONNECTING_IP = "1";
expect(
getClientIp(
stamped("198.51.100.7", { "cf-connecting-ip": "203.0.113.9" }),
),
).toBe("203.0.113.9");
});
it("reads an IPv4 peer that the socket reports in IPv6 form", () => {
expect(
getClientIp(