From 877db9a5b7cf14180f97288eaa7473c97eafe55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 15:59:30 +0100 Subject: [PATCH] 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. --- .env.example | 10 ++++- .env.production.example | 25 ++++++++----- docker-compose.yml | 6 ++- docs/self-hosting/reverse-proxy.md | 50 +++++++++++++++++++------ scripts/env-manifest.json | 7 +++- src/lib/__tests__/get-client-ip.test.ts | 42 +++++++++++++++++++++ 6 files changed, 114 insertions(+), 26 deletions(-) diff --git a/.env.example b/.env.example index 05fcca7d3..b398a7662 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/.env.production.example b/.env.production.example index 761792872..81356bfb9 100644 --- a/.env.production.example +++ b/.env.production.example @@ -554,21 +554,26 @@ API_TOKEN_HMAC_KEY="" # ----------------------------------------------------------------------------- # 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). diff --git a/docker-compose.yml b/docker-compose.yml index 292340e3a..957177d69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/self-hosting/reverse-proxy.md b/docs/self-hosting/reverse-proxy.md index 4ccbbfaf6..5dee21854 100644 --- a/docs/self-hosting/reverse-proxy.md +++ b/docs/self-hosting/reverse-proxy.md @@ -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 diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index f30a8e227..9d7f38d9c 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -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" }, { diff --git a/src/lib/__tests__/get-client-ip.test.ts b/src/lib/__tests__/get-client-ip.test.ts index 05b5a318a..6ef8182b0 100644 --- a/src/lib/__tests__/get-client-ip.test.ts +++ b/src/lib/__tests__/get-client-ip.test.ts @@ -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(