R-753: the box tells visitors apart — cloudflared at a fixed address, traefik trusts only it, the controller reads the hop traefik saw
- felhom-tunnel network 172.16.253.0/29 (ip-range .4/30): cloudflared alone at .2, traefik at .3; traefik's websecure trusts forwarded headers from 172.16.253.2/32 only, and every request passes felhom-forwarded@file, which removes the client-writable host/path/address headers (X-Forwarded-Host/-Uri/-Method/-Prefix, Forwarded, True-Client-Ip, …) and fixes X-Forwarded-Port to 443 (measured: Cloudflare passes a client's X-Forwarded-Host/-Port). - EnsureBaseStack reconciles a RUNNING traefik/cloudflared whose rendered files changed (recreate), refuses a rewrite that would drop a certificate resolver, and moves cloudflared only once traefik is on the tunnel network. - clientIP: believed only when the TCP peer is traefik; the rightmost X-Forwarded-For entry (the hop traefik saw); the tunnel hop → CF-Connecting-IP (the edge refuses a client-sent one, measured 403). rateKey: IPv6 per /64. Dashboard login, claim, share and escrow counters key on it; the setup gate logs it. - Dashboard login messages: keys, informal voice, both languages. Red-proofs: RP-A1 (leftmost hop), RP-A2 (shared tunnel key), RP-A3 (no reconcile) — felhom.eu audits/visitors-2026-10-01/A. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user