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
150 lines
5.5 KiB
Go
150 lines
5.5 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// rateLimitTestServer builds a minimal Server with a real password hash and an initialized
|
|
// loginAttempts map, templates loaded (renderLogin needs s.tmpl). No agent/backup/settings — the
|
|
// re-auth rate limiter fires before any of those.
|
|
func rateLimitTestServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
cfg := &config.Config{}
|
|
hash, _ := bcrypt.GenerateFromPassword([]byte("correct-pass"), bcrypt.MinCost)
|
|
cfg.Web.PasswordHash = string(hash)
|
|
s := &Server{cfg: cfg, logger: log.New(io.Discard, "", 0), version: "test",
|
|
loginAttempts: map[string]*loginAttempt{}, sessions: map[string]*session{}}
|
|
s.loadTemplates()
|
|
return s
|
|
}
|
|
|
|
func doLogin(s *Server, remoteAddr, xff, 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 = remoteAddr
|
|
if xff != "" {
|
|
r.Header.Set("X-Forwarded-For", xff)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
s.handleLogin(w, r)
|
|
return w
|
|
}
|
|
|
|
func ex(s string) string {
|
|
if len(s) > 200 {
|
|
return s[:200]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Scenario A (the F-B fix): 6 failed logins from ONE host over DISTINCT ephemeral ports and NO
|
|
// X-Forwarded-For must share a rate-limit key and trip the limiter on attempt 6. Pre-fix (raw
|
|
// RemoteAddr key) each port is its own key → never limited. This is the F-B red-proof anchor.
|
|
func TestLoginRateLimit_DirectDistinctPorts_Limited(t *testing.T) {
|
|
s := rateLimitTestServer(t)
|
|
var last string
|
|
for i := 1; i <= 6; i++ {
|
|
last = doLogin(s, fmt.Sprintf("127.0.0.1:%d", 5000+i), "", "wrong").Body.String()
|
|
if i < 6 && !strings.Contains(last, "Hibás jelszó") {
|
|
t.Fatalf("attempt %d expected Hibás jelszó, got: %s", i, ex(last))
|
|
}
|
|
}
|
|
if !strings.Contains(last, "Túl sok hibás próbálkozás") {
|
|
t.Fatalf("attempt 6 (distinct ports, no XFF) MUST be rate-limited; got: %s", ex(last))
|
|
}
|
|
}
|
|
|
|
// Scenario B (regression): stable X-Forwarded-For across 6 attempts still limits on 6.
|
|
func TestLoginRateLimit_StableXFF_Limited(t *testing.T) {
|
|
s := rateLimitTestServer(t)
|
|
var last string
|
|
for i := 1; i <= 6; i++ {
|
|
last = doLogin(s, fmt.Sprintf("10.9.9.9:%d", 5000+i), "203.0.113.9", "wrong").Body.String()
|
|
}
|
|
if !strings.Contains(last, "Túl sok hibás próbálkozás") {
|
|
t.Fatalf("attempt 6 with a stable XFF MUST be rate-limited; got: %s", ex(last))
|
|
}
|
|
}
|
|
|
|
// Scenario C (R-753, reversed on purpose): a peer that is NOT traefik wrote its X-Forwarded-For itself, so rotating it
|
|
// no longer evades the counter — the key is the TCP peer. (Before v0.286.0 this test pinned the evasion as "a future
|
|
// XFF-trust change is a conscious decision"; this is that decision, `09` §3 decision 63.)
|
|
func TestLoginRateLimit_RotatingXFF_Limited(t *testing.T) {
|
|
s := rateLimitTestServer(t)
|
|
var last string
|
|
for i := 1; i <= 6; i++ {
|
|
last = doLogin(s, "10.9.9.9:5000", fmt.Sprintf("203.0.113.%d", i), "wrong").Body.String()
|
|
}
|
|
if !strings.Contains(last, "Túl sok hibás próbálkozás") {
|
|
t.Fatalf("a rotating X-Forwarded-For from a direct peer must NOT evade the counter; got: %s", ex(last))
|
|
}
|
|
}
|
|
|
|
// Scenario D: the escrow wizard re-auth path shares the SAME fixed key (proves the shared clientIP
|
|
// helper reached escrow_handlers, not just handleLogin). 6 wrong wizard passwords over distinct
|
|
// ports → 429 on attempt 6.
|
|
func TestEscrowReauthRateLimit_SharesFixedKey(t *testing.T) {
|
|
s := rateLimitTestServer(t)
|
|
var code int
|
|
for i := 1; i <= 6; i++ {
|
|
form := url.Values{"password": {"wrong"}}
|
|
r := httptest.NewRequest(http.MethodPost, "/api/escrow/start", strings.NewReader(form.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 6000+i)
|
|
w := httptest.NewRecorder()
|
|
s.escrowStartAPIHandler(w, r)
|
|
code = w.Code
|
|
}
|
|
if code != http.StatusTooManyRequests {
|
|
t.Fatalf("escrow re-auth attempt 6 (distinct ports, no XFF) MUST be 429; got %d", code)
|
|
}
|
|
}
|
|
|
|
// Scenario E: a successful login clears the counter for that host key.
|
|
func TestLoginRateLimit_SuccessClearsCounter(t *testing.T) {
|
|
s := rateLimitTestServer(t)
|
|
for i := 1; i <= 4; i++ {
|
|
doLogin(s, fmt.Sprintf("127.0.0.1:%d", 7000+i), "", "wrong")
|
|
}
|
|
if s.loginAttempts["127.0.0.1"] == nil || s.loginAttempts["127.0.0.1"].count != 4 {
|
|
t.Fatalf("expected 4 accrued failures on the shared key before success")
|
|
}
|
|
doLogin(s, "127.0.0.1:7099", "", "correct-pass") // success
|
|
if s.loginAttempts["127.0.0.1"] != nil {
|
|
t.Fatalf("a successful login must clear the host's failure counter")
|
|
}
|
|
}
|
|
|
|
// clientIP unit (direct peers — the forwarded paths are TestClientIP_Paths in clientaddr_test.go): port stripped; a
|
|
// direct peer's own X-Forwarded-For is never believed (R-753).
|
|
func TestClientIP_StripsPort(t *testing.T) {
|
|
cases := []struct{ remote, xff, want string }{
|
|
{"127.0.0.1:5001", "", "127.0.0.1"},
|
|
{"127.0.0.1:5002", "203.0.113.9", "127.0.0.1"},
|
|
{"[::1]:443", "", "::1"},
|
|
{"192.168.0.5", "", "192.168.0.5"}, // no port → raw
|
|
{"10.0.0.1:80", "198.51.100.7, 203.0.113.9", "10.0.0.1"},
|
|
}
|
|
for _, c := range cases {
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.RemoteAddr = c.remote
|
|
if c.xff != "" {
|
|
r.Header.Set("X-Forwarded-For", c.xff)
|
|
}
|
|
if got := clientIP(r); got != c.want {
|
|
t.Errorf("clientIP(remote=%q xff=%q) = %q, want %q", c.remote, c.xff, got, c.want)
|
|
}
|
|
}
|
|
}
|