Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
8.5 KiB
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-IPonly when the hop traefik saw is cloudflared's address. Cost: apps keep "one address" for every tunnel visitor (R-775 Grimmory, Home Assistant'slocal_onlyhole, 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
forwardAuthfor 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@fileremoves 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 fixesX-Forwarded-Port: 443. An app that falls back from X-Forwarded-Host readsHost, 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-tunnel172.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 ontraefik-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"]andhttp.middlewares: [felhom-forwarded@file]. EnsureBaseStackreconciles a RUNNING traefik/cloudflared when the rendered files differ (recreate; refuses a rewrite that would drop the running certificate resolver); writes the middleware file beforetraefik.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 being172.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_onlyuser 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, kimaiTRUSTED_PROXIES, ziplineCORE_TRUST_PROXY/CORE_TRUSTED_PROXIES, vikunjaVIKUNJA_SERVICE_IPEXTRACTIONMETHOD=xff, nextcloudTRUSTED_PROXIES; JellyfinKnownProxies(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, karakeepRATE_LIMITING_ENABLED, onlyoffice ipfilter, vaultwardenIP_HEADER=X-Forwarded-For, PocketBaseUseLeftmostIP, navidrome's reverse-proxy whitelist (header login), PlexALLOWED_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
clientIPtakes 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.