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
This commit is contained in:
2026-10-01 21:01:42 +02:00
parent c1b123c649
commit 1e8d045815
19 changed files with 1006 additions and 83 deletions
+11 -11
View File
@@ -150,20 +150,20 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
password := r.FormValue("password")
nextURL := r.FormValue("next")
// The counter's key is the VISITOR (clientaddr.go, R-753): through the tunnel each visitor has its own address, so a
// stranger's wrong passwords lock only the stranger. Before v0.286.0 every tunnel visitor shared cloudflared's
// address here, and five wrong tries locked the whole household out of its own dashboard for a minute.
ip := rateKey(r)
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] login attempt from %s (X-Forwarded-For: %s)", r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
s.logger.Printf("[DEBUG] [web] login attempt: visitor %s (peer %s, X-Forwarded-For %q, CF-Connecting-IP %q)",
ip, r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("CF-Connecting-IP"))
}
if password == "" {
s.renderLogin(w, r, "Kérjük adja meg a jelszót", "")
s.renderLogin(w, r, s.msg(r, "login.msg.empty_password"), "")
return
}
// Rate limit: check failed attempts from this host. clientIP strips the ephemeral port
// (CAMPAIGN-4 F-B) so distinct direct connections from one host share a key and the counter
// actually accrues; XFF first-hop still wins for proxied clients.
ip := clientIP(r)
s.loginAttemptMu.Lock()
attempt := s.loginAttempts[ip]
if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration {
@@ -174,14 +174,14 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if attempt != nil && attempt.count >= loginMaxAttempts {
s.loginAttemptMu.Unlock()
s.logger.Printf("[WARN] [web] Login rate limited for %s (%d attempts)", ip, attempt.count)
s.renderLogin(w, r, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva", "")
s.renderLogin(w, r, s.msg(r, "login.msg.rate_limited"), "")
return
}
s.loginAttemptMu.Unlock()
effectiveHash := s.effectivePasswordHash()
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(password)); err != nil {
s.logger.Printf("[WARN] [web] Failed login from %s", r.RemoteAddr)
s.logger.Printf("[WARN] [web] Failed login from %s", ip)
s.loginAttemptMu.Lock()
if s.loginAttempts[ip] == nil {
s.loginAttempts[ip] = &loginAttempt{}
@@ -189,7 +189,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
s.loginAttempts[ip].count++
s.loginAttempts[ip].lastFail = time.Now()
s.loginAttemptMu.Unlock()
s.renderLogin(w, r, "Hibás jelszó", "")
s.renderLogin(w, r, s.msg(r, "login.msg.wrong_password"), "")
return
}
@@ -219,7 +219,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// browser is closed". The durable opt-out is a separate, explicit choice and is untouched here.
http.SetCookie(w, &http.Cookie{Name: recoveryBannerCookie, Value: "", Path: "/", MaxAge: -1})
s.logger.Printf("[INFO] [web] Login from %s", r.RemoteAddr)
s.logger.Printf("[INFO] [web] Login from %s", ip)
// Redirect to ?next= target if provided, otherwise to dashboard
redirectTo := "/"
+2 -23
View File
@@ -10,7 +10,6 @@ import (
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"io"
"net"
"net/http"
"os"
"strings"
@@ -217,27 +216,7 @@ func (s *Server) claimNow() time.Time {
return time.Now()
}
// clientIP returns the client IP used as the rate-limiter key. Order: the X-Forwarded-For first
// hop (set by the traefik/Cloudflare proxy) wins; otherwise the HOST portion of RemoteAddr with the
// ephemeral PORT stripped (net.SplitHostPort). This is the CAMPAIGN-4 F-B fix: keying on the raw
// RemoteAddr (IP:PORT) meant every fresh direct connection from one host got a distinct ephemeral
// port → a distinct key → the failed-attempt counter never accrued, so a direct-to-controller
// (LAN/guest, non-proxied) path had NO brute-force protection. A RemoteAddr with no port
// (tests/edge) or an IPv6 form is handled by SplitHostPort, falling back to the raw value.
//
// Accepted limitation (out of scope here): X-Forwarded-For is attacker-controlled on a direct path,
// so a client rotating the first hop still evades the per-IP counter. This fix only closes the
// port-in-key bug so the proxied / stable-source-IP case — the real deployment — works; it does NOT
// attempt to establish XFF trust.
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
return strings.TrimSpace(strings.Split(fwd, ",")[0])
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return strings.TrimSpace(r.RemoteAddr)
}
// clientIP and rateKey live in clientaddr.go (R-753).
// ── the pages ────────────────────────────────────────────────────────────────────────────────
@@ -316,7 +295,7 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
return
}
wasReset := s.authEnabled() // a password already set → this is a reset, not a first-claim
ip := clientIP(r)
ip := rateKey(r)
if locked, _ := s.claimRateLocked(); locked {
s.handleClaimPage(w, r, s.msg(r, "claim.msg.too_many"), "")
+129
View File
@@ -0,0 +1,129 @@
package web
import (
"context"
"net"
"net/http"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
)
// ── The visitor's address (R-753, `09` §3 decision 63, Part A) ─────────────────────────────────────────────
//
// The rule: NEVER BELIEVE AN ADDRESS A CLIENT CAN WRITE.
//
// Two paths reach the controller, both through traefik:
// tunnel: browser → Cloudflare's edge → cloudflared (felhom-tunnel, fixed infra.TunnelAddr) → traefik → controller
// LAN: browser → traefik → controller
// traefik APPENDS the address it saw to X-Forwarded-For, and drops any chain written by a peer it does not trust — so the
// RIGHTMOST X-Forwarded-For entry is the address traefik itself saw, on either path. That entry, and only that entry,
// is believed — and only when the request's own TCP peer IS traefik (anything else that can open a connection to the
// controller can write any header it likes).
//
// - The hop is cloudflared's fixed address → the visitor is CF-Connecting-IP. Cloudflare's edge sets it on every
// request and refuses a request that carries its own (measured: HTTP 403 at the edge,
// felhom.eu/documentation/audits/visitors-2026-10-01/A/M1-status-quo.txt). On the LAN the hop is the LAN client
// itself, so a CF-Connecting-IP forged on the LAN (it does arrive — M4) is never read.
// - Any other hop → the visitor is that hop.
//
// Everything else — the LEFTMOST X-Forwarded-For entry above all (Cloudflare APPENDS to a client-sent chain, measured
// M2: "6.6.6.6,37.191.56.193, 172.18.0.5") — is client-written and ignored. The rule holds whether or not traefik
// trusts the tunnel: the setup gate's forwardAuth request carries only traefik's own hop, and the dashboard request
// carries the whole chain; both end in the hop traefik saw.
//
// If cloudflared is NOT at its fixed address (a box whose tunnel network could not be made), the hop is cloudflared's
// docker-assigned address: the visitor is that address — every tunnel visitor shares one key, as before R-753. Never a
// client-written one.
//
// Pinned by internal/web/clientaddr_test.go (TestClientIP_*), red-proven against the leftmost-hop shape.
// traefikHost is the name the controller resolves traefik by on traefik-public (docker's embedded DNS).
const traefikHost = "traefik"
// isTraefikPeer reports whether ip is traefik's address. A variable so tests can name traefik without docker DNS.
var isTraefikPeer = func(ip string) bool { return traefikPeers.has(ip) }
var traefikPeers = &peerResolver{host: traefikHost, ttl: 30 * time.Second, lookup: net.DefaultResolver.LookupHost}
// peerResolver caches the addresses a container name resolves to. A failed lookup believes nobody (fail closed: the
// TCP peer is then the visitor, which can never be client-written).
type peerResolver struct {
host string
ttl time.Duration
lookup func(ctx context.Context, host string) ([]string, error)
mu sync.Mutex
at time.Time
addrs []string
}
func (p *peerResolver) has(ip string) bool {
p.mu.Lock()
defer p.mu.Unlock()
if time.Since(p.at) > p.ttl {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
addrs, err := p.lookup(ctx, p.host)
cancel()
if err != nil {
addrs = nil
}
p.addrs, p.at = addrs, time.Now()
}
for _, a := range p.addrs {
if a == ip {
return true
}
}
return false
}
// peerHost is RemoteAddr without its port (CAMPAIGN-4 F-B: a key with the ephemeral port never accrues).
func peerHost(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return strings.TrimSpace(r.RemoteAddr)
}
// clientIP is the visitor's address — logged, and the key of every per-visitor counter (dashboard login, claim code,
// share password, escrow re-auth). See the block comment above for the rule.
func clientIP(r *http.Request) string {
peer := peerHost(r)
if !isTraefikPeer(peer) {
return peer
}
var hops []string
for _, v := range r.Header.Values("X-Forwarded-For") {
for _, h := range strings.Split(v, ",") {
if h = strings.TrimSpace(h); h != "" {
hops = append(hops, h)
}
}
}
if len(hops) == 0 {
return peer
}
hop := hops[len(hops)-1] // the address traefik saw
if net.ParseIP(hop) == nil {
return peer
}
if hop == infra.TunnelAddr {
if cf := strings.TrimSpace(r.Header.Get("CF-Connecting-IP")); net.ParseIP(cf) != nil {
return cf
}
}
return hop
}
// rateKey is clientIP as a counter key. An IPv6 visitor is counted per /64: one household or one attacker usually holds
// a whole /64, and per-/128 keys would let one machine step around a counter by changing its own address.
func rateKey(r *http.Request) string {
ip := clientIP(r)
if p := net.ParseIP(ip); p != nil && p.To4() == nil {
return p.Mask(net.CIDRMask(64, 128)).String() + "/64"
}
return ip
}
+206
View File
@@ -0,0 +1,206 @@
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)
}
}
}
+1 -1
View File
@@ -184,7 +184,7 @@ func (s *Server) escrowStartAPIHandler(w http.ResponseWriter, r *http.Request) {
escrowJSON(w, http.StatusForbidden, nil, "A vezérlőpult jelszava nincs beállítva — előbb állítson be jelszót.")
return
}
ip := clientIP(r)
ip := rateKey(r)
if s.escrowRateLimited(ip) {
s.logger.Printf("[WARN] [web] escrow start rate limited for %s", ip)
escrowJSON(w, http.StatusTooManyRequests, nil, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva.")
+12 -14
View File
@@ -60,7 +60,7 @@ func TestLoginRateLimit_DirectDistinctPorts_Limited(t *testing.T) {
t.Fatalf("attempt %d expected Hibás jelszó, got: %s", i, ex(last))
}
}
if !strings.Contains(last, "Túl sok sikertelen") {
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))
}
}
@@ -72,25 +72,22 @@ func TestLoginRateLimit_StableXFF_Limited(t *testing.T) {
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") {
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 (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) {
// 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 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))
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))
}
}
@@ -129,14 +126,15 @@ func TestLoginRateLimit_SuccessClearsCounter(t *testing.T) {
}
}
// clientIP unit: port stripped; XFF first-hop wins; no-port and IPv6 handled.
// 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", "203.0.113.9"},
{"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", "198.51.100.7"},
{"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)
+3 -3
View File
@@ -220,7 +220,7 @@ func (s *Server) ServeGateAuth(w http.ResponseWriter, r *http.Request) {
if u, err := url.Parse(uri); err == nil && u.Path == gateCallbackURI {
rd, err := s.takeGateToken(u.Query().Get("t"), host)
if err != nil {
s.logger.Printf("[WARN] [web] setup gate %s: a sign-in token was refused (%v)", app, err)
s.logger.Printf("[WARN] [web] setup gate %s: a sign-in token was refused (%v) — visitor %s", app, err, clientIP(r))
gateRefuse(w, http.StatusForbidden)
return
}
@@ -229,7 +229,7 @@ func (s *Server) ServeGateAuth(w http.ResponseWriter, r *http.Request) {
Name: gateCookieName, Value: exp + "." + s.gateMAC("cookie", host, exp), Path: "/",
MaxAge: int(gateCookieLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})
s.logger.Printf("[INFO] [web] setup gate %s: the household passed (a dashboard session vouched for this browser)", app)
s.logger.Printf("[INFO] [web] setup gate %s: the household passed (a dashboard session vouched for this browser) — visitor %s", app, clientIP(r))
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, rd, http.StatusFound)
return
@@ -245,7 +245,7 @@ func (s *Server) ServeGateAuth(w http.ResponseWriter, r *http.Request) {
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] setup gate %s: %s %s without a pass — 401", app, method, uri)
s.logger.Printf("[DEBUG] [web] setup gate %s: %s %s without a pass — 401 (visitor %s)", app, method, uri, clientIP(r))
}
gateRefuse(w, http.StatusUnauthorized)
}
+1 -1
View File
@@ -113,7 +113,7 @@ func (s *Server) shareGuestPasswordHandler(w http.ResponseWriter, r *http.Reques
s.renderSharePasswordPage(w, r, "Érvénytelen űrlap — töltse újra az oldalt.")
return
}
ip := clientIP(r)
ip := rateKey(r)
if s.shareRateLimited(ip) {
s.logger.Printf("[WARN] [web] share password rate limited for %s", ip)
s.renderSharePasswordPage(w, r, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva")
+1 -1
View File
@@ -19,7 +19,7 @@
<div class="form-group">
<label for="password">Jelszó</label>
<input type="password" id="password" name="password" required autofocus
placeholder="Adja meg a jelszavát" class="form-control">
placeholder="Add meg a jelszavad" class="form-control">
</div>
<button type="submit" class="btn btn-primary btn-full">Bejelentkezés</button>
</form>