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:
@@ -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 := "/"
|
||||
|
||||
@@ -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"), "")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user