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(