v0.129.0: CAMPAIGN-4 fixes — rate-limiter key (F-B) + volume-blind estimate (F-A) + no-op claim status (F-C)

F-B (MED, security): shared clientIP(r) helper (XFF first-hop, else SplitHostPort
host, else raw) replaces requestIP + the duplicated inline derivation in handleLogin,
so login AND escrow re-auth key on the port-stripped host IP — distinct direct
connections no longer evade the failed-attempt counter. XFF-trust out of scope (commented).

F-A (MED, honesty): volumeSizer seam reads volume size from a container view
(docker run --rm -v vol:/vol:ro alpine du -sb /vol), replacing the host-path du that
returned 0 inside the containerized controller. Failed read -> size_unknown +
fits_on_dest forced false (never "fits"). Export pre-flight hard-aborts only on a
KNOWN doesn.t-fit. HDD branch unchanged.

F-C (LOW-MED): escrowClaimAPIHandler relays agent 404 -> clean 404 and 409 -> 409;
410 and genuine-unreachable 502 unchanged (was: 404 fell through to 502).

Tests + red-proofs: ratelimit_ip_test.go (F-B x6), estimate_volsize_test.go (F-A x3),
TestEscrowClaim_ProxySemantics +3 (F-C). Alpine busybox du -sb verified prod-valid.

Claude-Session: https://claude.ai/code/session_01LbMm4T7Ayzs1unB9pN6Uqd
@
This commit is contained in:
2026-07-14 09:52:11 +02:00
parent 3c9de42c20
commit 7465713a2f
10 changed files with 414 additions and 35 deletions
@@ -0,0 +1,151 @@
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 sikertelen") {
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 sikertelen") {
t.Fatalf("attempt 6 with a stable XFF MUST be rate-limited; got: %s", ex(last))
}
}
// Scenario C (documented accepted limitation): rotating the X-Forwarded-For first hop evades the
// per-IP counter. This is NOT what the fix targets (XFF is attacker-controlled on a direct path);
// the test pins the known behavior so a future XFF-trust change is a conscious decision.
func TestLoginRateLimit_RotatingXFF_NotLimited(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 sikertelen") {
t.Fatalf("rotating XFF is a known evasion (out of scope) — expected NOT limited")
}
if !strings.Contains(last, "Hibás jelszó") {
t.Fatalf("expected Hibás jelszó on rotating XFF; 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: port stripped; XFF first-hop wins; no-port and IPv6 handled.
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", "203.0.113.9"},
{"[::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", "198.51.100.7"},
}
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)
}
}
}