Files
felhom-controller/controller/internal/web/clientaddr_test.go
T
admin 1e8d045815 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
2026-10-01 21:01:42 +02:00

207 lines
8.4 KiB
Go

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: "<client-written>, <real>, <cloudflared>" — 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)
}
}
}