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

8.5 KiB
Raw Blame History

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.