1e8d045815
- 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
207 lines
8.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|