b50289074d
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
97 lines
8.5 KiB
Markdown
97 lines
8.5 KiB
Markdown
# 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`.
|