package web import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/infra" ) // R-753 (`09` §3 decision 63, Part A) — "never believe an address a client can write". The shapes below are the // MEASURED ones (felhom.eu/documentation/audits/visitors-2026-10-01/A): Cloudflare appends the real visitor to a // client-sent X-Forwarded-For, traefik appends the hop it saw, and a CF-Connecting-IP forged on the LAN arrives. const traefikAddr = "172.18.0.3" func withTraefikAt(t *testing.T, addrs ...string) { t.Helper() old := isTraefikPeer isTraefikPeer = func(ip string) bool { for _, a := range addrs { if a == ip { return true } } return false } t.Cleanup(func() { isTraefikPeer = old }) } func addrReq(remote, xff, cf string) *http.Request { r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = remote if xff != "" { r.Header.Set("X-Forwarded-For", xff) } if cf != "" { r.Header.Set("CF-Connecting-IP", cf) } return r } func TestClientIP_Paths(t *testing.T) { withTraefikAt(t, traefikAddr) tun := infra.TunnelAddr cases := []struct { name, remote, xff, cf, want string }{ // tunnel, traefik trusting the tunnel: ", , " — the forged LEFTMOST is not believed {"tunnel, forged leftmost", traefikAddr + ":5000", "6.6.6.6,37.191.56.193, " + tun, "37.191.56.193", "37.191.56.193"}, {"tunnel, plain", traefikAddr + ":5000", "37.191.56.193, " + tun, "37.191.56.193", "37.191.56.193"}, // the setup gate's forwardAuth request: traefik writes only the hop it saw {"gate request through the tunnel", traefikAddr + ":5000", tun, "203.0.113.50", "203.0.113.50"}, // LAN: traefik replaced the chain with the LAN client; a CF-Connecting-IP forged on the LAN is never read {"LAN, forged CF-Connecting-IP", traefikAddr + ":5000", "192.168.0.180", "7.7.7.7", "192.168.0.180"}, // a peer that is NOT traefik wrote every header itself: only the TCP peer counts {"direct, forged headers", "192.168.0.50:4000", "1.2.3.4", "5.6.7.8", "192.168.0.50"}, {"direct, no headers", "127.0.0.1:5001", "", "", "127.0.0.1"}, {"direct, no port", "192.168.0.5", "", "", "192.168.0.5"}, {"direct IPv6", "[::1]:443", "", "", "::1"}, // cloudflared not (yet) at its fixed address: the old shared address, never a client-written one {"old cloudflared address", traefikAddr + ":5000", "6.6.6.6, 172.18.0.5", "9.9.9.9", "172.18.0.5"}, {"tunnel hop without CF-Connecting-IP", traefikAddr + ":5000", tun, "", tun}, {"tunnel hop, garbage CF-Connecting-IP", traefikAddr + ":5000", tun, "not-an-ip", tun}, {"traefik, garbage hop", traefikAddr + ":5000", "1.2.3.4, garbage", "", traefikAddr}, {"traefik, no XFF", traefikAddr + ":5000", "", "", traefikAddr}, } for _, c := range cases { if got := clientIP(addrReq(c.remote, c.xff, c.cf)); got != c.want { t.Errorf("%s: clientIP(remote=%q xff=%q cf=%q) = %q, want %q", c.name, c.remote, c.xff, c.cf, got, c.want) } } } // Two X-Forwarded-For header LINES are one chain; the last entry of the last line is the hop. func TestClientIP_MultipleXFFLines(t *testing.T) { withTraefikAt(t, traefikAddr) r := addrReq(traefikAddr+":1", "", "203.0.113.7") r.Header.Add("X-Forwarded-For", "6.6.6.6") r.Header.Add("X-Forwarded-For", "203.0.113.7, "+infra.TunnelAddr) if got := clientIP(r); got != "203.0.113.7" { t.Fatalf("got %q", got) } } func TestRateKey_IPv6Per64(t *testing.T) { withTraefikAt(t, traefikAddr) a := rateKey(addrReq(traefikAddr+":1", infra.TunnelAddr, "2001:db8:1:2:aaaa::1")) b := rateKey(addrReq(traefikAddr+":1", infra.TunnelAddr, "2001:db8:1:2:bbbb::9")) c := rateKey(addrReq(traefikAddr+":1", infra.TunnelAddr, "2001:db8:1:3::1")) if a != b || a != "2001:db8:1:2::/64" || a == c { t.Fatalf("one /64 must be one key: %q %q %q", a, b, c) } if k := rateKey(addrReq(traefikAddr+":1", infra.TunnelAddr, "203.0.113.9")); k != "203.0.113.9" { t.Fatalf("IPv4 key = %q", k) } } // The production resolver believes nobody when docker's DNS does not answer (fail closed), and caches an answer. func TestPeerResolver_FailsClosedAndCaches(t *testing.T) { n := 0 p := &peerResolver{host: "traefik", ttl: time.Minute, lookup: func(context.Context, string) ([]string, error) { n++ return nil, errors.New("no such host") }} if p.has("172.18.0.3") { t.Fatal("a failed lookup must believe nobody") } ok := &peerResolver{host: "traefik", ttl: time.Minute, lookup: func(context.Context, string) ([]string, error) { n++ return []string{"172.18.0.3"}, nil }} if !ok.has("172.18.0.3") || ok.has("172.18.0.4") || n != 2 { t.Fatalf("resolver answer not used or not cached (lookups %d)", n) } if isTraefikPeer == nil || traefikPeers.host != traefikHost { t.Fatal("the production seam must resolve the traefik container by name") } } func tunnelLogin(s *Server, visitor, forgedLeft, password string) *httptest.ResponseRecorder { form := url.Values{"password": {password}} r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.RemoteAddr = traefikAddr + ":44321" xff := visitor + ", " + infra.TunnelAddr if forgedLeft != "" { xff = forgedLeft + "," + xff } r.Header.Set("X-Forwarded-For", xff) r.Header.Set("CF-Connecting-IP", visitor) w := httptest.NewRecorder() s.handleLogin(w, r) return w } // THE CONSEQUENCE (R-753): through the tunnel, a stranger's wrong passwords lock only the stranger — rotating a forged // leftmost address does not get him out — and the household, from another address, signs in at once. // Red-proof: with the pre-R-753 clientIP (leftmost X-Forwarded-For hop) the stranger's rotation is never locked, and // with a key of cloudflared's address the household is refused. func TestLogin_StrangerThroughTheTunnelLocksOnlyHimself(t *testing.T) { withTraefikAt(t, traefikAddr) s := rateLimitTestServer(t) stranger, household := "198.51.100.66", "203.0.113.10" var last string for i := 1; i <= 7; i++ { last = tunnelLogin(s, stranger, fmt.Sprintf("10.0.0.%d", i), "wrong").Body.String() } if !strings.Contains(last, "Túl sok hibás próbálkozás") { t.Fatalf("the stranger rotating a forged leftmost address must be locked after 5 tries; got: %s", ex(last)) } w := tunnelLogin(s, household, "", "correct-pass") if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Set-Cookie"), sessionCookieName+"=") { t.Fatalf("the household must sign in at once from its own address; got %d %s", w.Code, ex(w.Body.String())) } if s.loginAttempts[stranger] == nil || s.loginAttempts[stranger].count != loginMaxAttempts { t.Fatalf("the stranger's own counter must hold the tries; got %+v", s.loginAttempts) } } // The dashboard login's three messages are keys (informal voice, v0.286.0) and follow the reader's language — the // sign-in page is met with no session, so the language cookie decides. Asserted both ways: the English page carries the // English and NOT the Hungarian. func TestLoginMessagesFollowTheReader(t *testing.T) { withTraefikAt(t, traefikAddr) s := rateLimitTestServer(t) post := func(lang, visitor, password string) string { form := url.Values{"password": {password}} r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.RemoteAddr = "192.168.0." + visitor + ":4000" r.AddCookie(&http.Cookie{Name: langCookieName, Value: lang}) w := httptest.NewRecorder() s.handleLogin(w, r) return w.Body.String() } type msg struct{ en, hu string } wrong := msg{"Wrong password.", "Hibás jelszó."} empty := msg{"Enter your password.", "Add meg a jelszavad."} locked := msg{"Too many wrong tries from this address. Try again in a minute.", "Túl sok hibás próbálkozás erről a címről. Próbáld újra egy perc múlva."} for _, c := range []struct { lang, visitor string m msg tries int pw string }{{"en", "11", wrong, 1, "x"}, {"hu", "12", wrong, 1, "x"}, {"en", "13", empty, 1, ""}, {"hu", "14", empty, 1, ""}, {"en", "15", locked, 6, "x"}, {"hu", "16", locked, 6, "x"}} { var out string for i := 0; i < c.tries; i++ { out = post(c.lang, c.visitor, c.pw) } want, not := c.m.hu, c.m.en if c.lang == "en" { want, not = c.m.en, c.m.hu } if !strings.Contains(out, want) || strings.Contains(out, not) { t.Errorf("%s: want %q and not %q", c.lang, want, not) } } }