Files
felhom.eu/documentation/audits/visitors-2026-10-01/A/DESIGN.md
T

97 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Part A — the box tells visitors apart (R-753): design, measurements, decision
Written 2026-10-01 evening, before the release was built (the build followed the measurements below; one bug the hand
prototype found is folded in). Rule that binds every choice: **never believe an address a client can write.**
## 1. The paths, measured
Two outside addresses were available: DooPlex's public IPv4 `37.191.56.193` (no IPv6), and ep0 (one request, used for the
live proof in §6, not here). Venue: demo-hp's REAL tunnel (`*.enkisfelhom.hu → https://traefik`) and an echo app
(`traefik/whoami:v1.11`) on demo-hp, removed afterwards.
| file | what |
|---|---|
| `M1-status-quo.txt` | through the tunnel, today's traefik (trusts nothing): XFF and X-Real-Ip = cloudflared `172.18.0.5` for EVERY visitor; `CF-Connecting-IP` = the visitor; a client's `Forwarded`, `True-Client-Ip` pass traefik untouched; **a client-sent `CF-Connecting-IP` is refused by Cloudflare's edge with 403** |
| `M2-what-cloudflared-delivers.txt` | traefik `insecure` for one minute (shows what arrives): `X-Forwarded-For: 6.6.6.6,37.191.56.193, 172.18.0.5` — **Cloudflare APPENDS the visitor to a client-written chain**; a client's `X-Real-IP` does NOT arrive (stripped); a client's **`X-Forwarded-Host: evil.example` and `X-Forwarded-Port: 8443` DO arrive**; `X-Forwarded-Proto` is overwritten (`https`) |
| `M3-restored.txt` | traefik back as it was |
| `M4-lan-path.txt` | LAN, forged headers: XFF / X-Real-Ip = the real LAN address (traefik drops the forged chain); **a forged `CF-Connecting-IP: 7.7.7.7` arrives** |
| path | TCP peer at traefik | XFF traefik forwards today | the real visitor is in | forgeable by the visitor |
|---|---|---|---|---|
| tunnel | cloudflared, docker-assigned (`172.18.0.5`) | cloudflared's address, for everyone | `CF-Connecting-IP`; Cloudflare's XFF (rightmost of its part) | XFF leftmost: yes (once trusted); CF-Connecting-IP: no (edge 403) |
| LAN | the LAN client | the LAN client | XFF / X-Real-Ip | no (traefik drops a forged chain); CF-Connecting-IP: YES |
## 2. Options, and the one taken
**Question:** how do the box and its apps learn each visitor's own address, without believing anything a client writes?
- **(a) Controller only.** No traefik change; the controller believes `CF-Connecting-IP` only when the hop traefik saw is
cloudflared's address. Cost: apps keep "one address" for every tunnel visitor (R-775 Grimmory, Home Assistant's
`local_only` hole, Kimai/zipline/vikunja lockouts stay); cloudflared's address must be fixed anyway.
- **(b) traefik trusts cloudflared's fixed address** (the reviewer's sketch). Apps that read X-Forwarded-For from the RIGHT
get the real visitor; the controller the same. Cost: (1) every app that reads the LEFTMOST entry would believe a
stranger's address (the sweep found 19); (2) traefik then keeps a client's `X-Forwarded-Host`/`-Port` (M2) — host-header
poisoning for apps that build links from it.
- **(c) A traefik plugin or our controller as `forwardAuth` for every request** to rewrite the chain to one address.
Cost: a new external dependency (plugin), or the controller in every request path (an outage takes every app down).
**Taken: (b), with both of its costs paid in the same rollout.** It is the only one that gives the APPS the visitor
(decision 63's purpose), needs no new dependency, and keeps the controller out of the request path.
- Cost (2): an entrypoint middleware `felhom-forwarded@file` removes every header a client could write a host, path or
address into (`X-Forwarded-Host/-Uri/-Method/-Prefix/-Tls-Client-Cert(-Info)`, `Forwarded`, `True-Client-Ip`,
`X-Client-Ip`, `X-Cluster-Client-Ip`, `Client-Ip`, `X-Original-Forwarded-For`) and fixes `X-Forwarded-Port: 443`.
An app that falls back from X-Forwarded-Host reads `Host`, which names the same app.
- Cost (1): the 19 leftmost readers carry a router middleware that removes the chain (`<router>-xff`); measured (P1) that
such an app then receives NO X-Forwarded-For and reads X-Real-Ip (traefik-set) or its peer — exactly as unforgeable as
today. Shipped in the catalog BEFORE the controller release (harmless without the trust).
**Docs quoted.** traefik (v3.6, `doc.traefik.io/traefik/reference/install-configuration/entrypoints`): *"forwardedHeaders.
trustedIPs — Trust only forwarded headers from selected IPs"*; the forwardAuth reference: *"trustForwardHeader is deprecated
… configure trusted IPs at the EntryPoint level using forwardedHeaders.trustedIPs"*. traefik source v3.6.7
(`pkg/middlewares/forwardedheaders/forwarded_header.go`): an untrusted peer's `X-Forwarded-*`/`X-Real-Ip` are DELETED;
a trusted peer's are KEPT and `X-Real-Ip` is set only when absent. Cloudflare's HTTP-headers page: X-Forwarded-For — *"If
an X-Forwarded-For header was already present in the request to Cloudflare, Cloudflare appends the IP address of the HTTP
proxy connecting to Cloudflare"* — measured in M2.
## 3. The shape built (controller v0.286.x, `internal/infra` + `internal/stacks/infra.go`)
- Network `felhom-tunnel` `172.16.253.0/29`, gateway `.1`, docker's allocation confined to `--ip-range 172.16.253.4/30`;
cloudflared ALONE on it at `.2`, traefik at `.3` (and on `traefik-public`). Why 172.16.x: private (apps' default proxy
lists — Tomcat, Rack, remote_ip — skip it) and outside docker's default pools (172.17–172.31, 192.168). **Found by the
hand prototype on 9202 (P1): without the ip-range and traefik's own fixed address, traefik joining first was given `.2`.**
- traefik `websecure`: `forwardedHeaders.trustedIPs: ["172.16.253.2/32"]` and `http.middlewares: [felhom-forwarded@file]`.
- `EnsureBaseStack` reconciles a RUNNING traefik/cloudflared when the rendered files differ (recreate; refuses a rewrite that
would drop the running certificate resolver); writes the middleware file before `traefik.yml`; moves cloudflared only
once traefik is on the tunnel network. If the network cannot be made, nothing is trusted and cloudflared stays put.
- **One rule for the controller** (`internal/web/clientaddr.go`): believed only when the TCP peer is traefik (docker DNS);
the RIGHTMOST X-Forwarded-For entry is the hop traefik saw; that hop being `172.16.253.2` → `CF-Connecting-IP`. It holds
for the dashboard (the whole chain) and the setup gate's forwardAuth request (only traefik's hop), and with or without
the trust. Readers in apps: from the RIGHT, skipping trusted proxies — **a fixed count from the right is wrong for one of
the two paths** (tunnel: 2nd from the right; LAN: 1st), so count-based readers (calibre-web, tandoor, wger) are left as
they are.
## 4. What it gives the apps (sweep, READ in source — `sweep/sweep-1..4.md`)
- **Real visitor with no change:** actualbudget, immich, dawarich, claper (tunnel), termix, **Home Assistant** (it treated
every internet visitor as "local" — a `local_only` user could sign in from the internet; fixed by this), **Grimmory**
(R-775: its IP lock becomes per visitor; the per-NAME lock stays).
- **Need one setting to see it** (catalog, after the release): bookstack `APP_PROXIES`, kimai `TRUSTED_PROXIES`, zipline
`CORE_TRUST_PROXY`/`CORE_TRUSTED_PROXIES`, vikunja `VIKUNJA_SERVICE_IPEXTRACTIONMETHOD=xff`, nextcloud `TRUSTED_PROXIES`;
Jellyfin `KnownProxies` (no env — `network.xml`).
- **Chain removed on their router (19):** adventurelog, audiobookshelf, code-server, docmost, emby, ghost, gokapi, komga,
mealie, opengist, outline, paperless-ngx, papra, plant-it, rallly, romm, seerr, sparkyfitness, uptime-kuma.
- **Stay "one address" on the tunnel, unforgeable:** X-Real-Ip readers (vaultwarden, grafana, gitea, crafty, homebox),
count readers (calibre-web, tandoor, wger), peer readers (gramps-web, navidrome, radicale, wanderer, privatebin).
- **Settings that must never be turned on** (they read the leftmost): glance `proxied`, karakeep `RATE_LIMITING_ENABLED`,
onlyoffice ipfilter, vaultwarden `IP_HEADER=X-Forwarded-For`, PocketBase `UseLeftmostIP`, navidrome's reverse-proxy
whitelist (header login), Plex `ALLOWED_NETWORKS`.
## 5. Risks stated
- A box that rolls back to ≤ 0.285 keeps the new traefik (an old controller never rewrites a running traefik); its
`clientIP` takes the LEFTMOST entry, which a stranger then writes — the dashboard's counter becomes dodgeable until the box
moves forward. The floor never moves back; the self-update's crash roll-back is the window. Row filed.
- A NEW catalog app that reads the leftmost entry is forgeable unless its onboarding finds it — checklist row added.
- Emby: every tunnel visitor is "LAN" today and stays so (its chain is removed); Jellyfin likewise until `KnownProxies`.