package web import ( "context" "net" "net/http" "strings" "sync" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/infra" ) // ── The visitor's address (R-753, `09` §3 decision 63, Part A) ───────────────────────────────────────────── // // The rule: NEVER BELIEVE AN ADDRESS A CLIENT CAN WRITE. // // Two paths reach the controller, both through traefik: // tunnel: browser → Cloudflare's edge → cloudflared (felhom-tunnel, fixed infra.TunnelAddr) → traefik → controller // LAN: browser → traefik → controller // traefik APPENDS the address it saw to X-Forwarded-For, and drops any chain written by a peer it does not trust — so the // RIGHTMOST X-Forwarded-For entry is the address traefik itself saw, on either path. That entry, and only that entry, // is believed — and only when the request's own TCP peer IS traefik (anything else that can open a connection to the // controller can write any header it likes). // // - The hop is cloudflared's fixed address → the visitor is CF-Connecting-IP. Cloudflare's edge sets it on every // request and refuses a request that carries its own (measured: HTTP 403 at the edge, // felhom.eu/documentation/audits/visitors-2026-10-01/A/M1-status-quo.txt). On the LAN the hop is the LAN client // itself, so a CF-Connecting-IP forged on the LAN (it does arrive — M4) is never read. // - Any other hop → the visitor is that hop. // // Everything else — the LEFTMOST X-Forwarded-For entry above all (Cloudflare APPENDS to a client-sent chain, measured // M2: "6.6.6.6,37.191.56.193, 172.18.0.5") — is client-written and ignored. The rule holds whether or not traefik // trusts the tunnel: the setup gate's forwardAuth request carries only traefik's own hop, and the dashboard request // carries the whole chain; both end in the hop traefik saw. // // If cloudflared is NOT at its fixed address (a box whose tunnel network could not be made), the hop is cloudflared's // docker-assigned address: the visitor is that address — every tunnel visitor shares one key, as before R-753. Never a // client-written one. // // Pinned by internal/web/clientaddr_test.go (TestClientIP_*), red-proven against the leftmost-hop shape. // traefikHost is the name the controller resolves traefik by on traefik-public (docker's embedded DNS). const traefikHost = "traefik" // isTraefikPeer reports whether ip is traefik's address. A variable so tests can name traefik without docker DNS. var isTraefikPeer = func(ip string) bool { return traefikPeers.has(ip) } var traefikPeers = &peerResolver{host: traefikHost, ttl: 30 * time.Second, lookup: net.DefaultResolver.LookupHost} // peerResolver caches the addresses a container name resolves to. A failed lookup believes nobody (fail closed: the // TCP peer is then the visitor, which can never be client-written). type peerResolver struct { host string ttl time.Duration lookup func(ctx context.Context, host string) ([]string, error) mu sync.Mutex at time.Time addrs []string } func (p *peerResolver) has(ip string) bool { p.mu.Lock() defer p.mu.Unlock() if time.Since(p.at) > p.ttl { ctx, cancel := context.WithTimeout(context.Background(), time.Second) addrs, err := p.lookup(ctx, p.host) cancel() if err != nil { addrs = nil } p.addrs, p.at = addrs, time.Now() } for _, a := range p.addrs { if a == ip { return true } } return false } // peerHost is RemoteAddr without its port (CAMPAIGN-4 F-B: a key with the ephemeral port never accrues). func peerHost(r *http.Request) string { if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { return host } return strings.TrimSpace(r.RemoteAddr) } // clientIP is the visitor's address — logged, and the key of every per-visitor counter (dashboard login, claim code, // share password, escrow re-auth). See the block comment above for the rule. func clientIP(r *http.Request) string { peer := peerHost(r) if !isTraefikPeer(peer) { return peer } var hops []string for _, v := range r.Header.Values("X-Forwarded-For") { for _, h := range strings.Split(v, ",") { if h = strings.TrimSpace(h); h != "" { hops = append(hops, h) } } } if len(hops) == 0 { return peer } hop := hops[len(hops)-1] // the address traefik saw if net.ParseIP(hop) == nil { return peer } if hop == infra.TunnelAddr { if cf := strings.TrimSpace(r.Header.Get("CF-Connecting-IP")); net.ParseIP(cf) != nil { return cf } } return hop } // rateKey is clientIP as a counter key. An IPv6 visitor is counted per /64: one household or one attacker usually holds // a whole /64, and per-/128 keys would let one machine step around a counter by changing its own address. func rateKey(r *http.Request) string { ip := clientIP(r) if p := net.ParseIP(ip); p != nil && p.To4() == nil { return p.Mask(net.CIDRMask(64, 128)).String() + "/64" } return ip }