From 3ea04f5530fbb62fb7db5c1a76ca2788a23fb586 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 26 Jul 2026 21:08:49 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(webserver):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20X-Forwarded-For=20=E5=8F=AF=E7=BB=95=E8=BF=87?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=A4=B1=E8=B4=A5=E9=94=81=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clientIP 改为以不可伪造的直连对端地址为准:该头完全由客户端控制,原实现无条件采信其首值, 直连攻击者每次请求换一个值即可拿到全新限流桶,彻底绕过 5 次失败锁定 (口令下限仅 6 位可在线爆破),并使 attempts map 无界增长;且每次尝试都会触发一次 64 MiB 的 Argon2id 推导,形成认证面的 CPU/内存 DoS - 新增 GONAVI_WEB_TRUSTED_PROXIES(IP/CIDR 逗号分隔):仅当对端命中该列表时才采信 X-Forwarded-For,并取最右侧的非可信跳(左侧可被客户端预先伪造);非法值不予采信, 避免用任意字符串制造新桶 - loginAttemptTracker 增加条目上限与过期清扫:条目原先只在登录成功或同 key 再次 allow 时 才删除,分布式来源下会单向增长 - docker.web-server.env.example 补充新变量说明(含反代部署下必须配置的提示) - 补 4 项回归测试,已确认还原为采信 XFF 首值后必定失败 --- docker.web-server.env.example | 8 ++ internal/webserver/auth.go | 142 +++++++++++++++++++--- internal/webserver/auth_client_ip_test.go | 133 ++++++++++++++++++++ 3 files changed, 264 insertions(+), 19 deletions(-) create mode 100644 internal/webserver/auth_client_ip_test.go diff --git a/docker.web-server.env.example b/docker.web-server.env.example index cb8633f1..62203dbe 100644 --- a/docker.web-server.env.example +++ b/docker.web-server.env.example @@ -17,3 +17,11 @@ GONAVI_WEB_HTTP_PORT=34116 # 设置或修改后请执行 docker compose up -d --force-recreate;请限制本文件的读取权限。 # 新部署如需启用 2FA,请先留空并通过 /setup 初始化,再设置为同一密码并重新创建容器。 GONAVI_WEB_PASSWORD= + +# 可信反向代理的 IP / CIDR 列表(逗号分隔)。仅当直连对端命中该列表时, +# 登录限流与失败锁定才会采信 X-Forwarded-For 里的客户端地址。 +# 留空(默认)时一律以不可伪造的直连对端地址为准——该头完全由客户端控制, +# 无条件采信会让攻击者逐请求更换该头即绕过 5 次失败锁定。 +# 若本服务部署在 Nginx/Traefik/负载均衡之后,请填写这些代理的地址, +# 否则所有客户端会共用同一个限流桶。例:GONAVI_WEB_TRUSTED_PROXIES=10.0.0.0/8,172.18.0.1 +GONAVI_WEB_TRUSTED_PROXIES= diff --git a/internal/webserver/auth.go b/internal/webserver/auth.go index 524c672b..e636cdf3 100644 --- a/internal/webserver/auth.go +++ b/internal/webserver/auth.go @@ -30,20 +30,21 @@ import ( ) const ( - webAuthConfigFileName = "web_auth.json" - webAuthSchemaVersion = 1 - webSessionCookieName = "gonavi_web_session" - webSetupTokenTTL = 10 * time.Minute - webLoginFailureWindow = 10 * time.Minute - webLoginFailureLimit = 5 - webLoginBlockDuration = 5 * time.Minute - webDefaultSessionIdleMinutes = 30 - webDefaultSessionAbsoluteHours = 24 * 7 - webDefaultSessionRememberDays = 7 - webMinPasswordLength = 6 - webAuthPasswordEnvName = "GONAVI_WEB_PASSWORD" - webTOTPPeriodSeconds = 30 - webTOTPDigits = 6 + webAuthConfigFileName = "web_auth.json" + webAuthSchemaVersion = 1 + webSessionCookieName = "gonavi_web_session" + webSetupTokenTTL = 10 * time.Minute + webLoginFailureWindow = 10 * time.Minute + webLoginFailureLimit = 5 + webLoginAttemptTrackerMaxEntries = 4096 + webLoginBlockDuration = 5 * time.Minute + webDefaultSessionIdleMinutes = 30 + webDefaultSessionAbsoluteHours = 24 * 7 + webDefaultSessionRememberDays = 7 + webMinPasswordLength = 6 + webAuthPasswordEnvName = "GONAVI_WEB_PASSWORD" + webTOTPPeriodSeconds = 30 + webTOTPDigits = 6 ) var ( @@ -247,12 +248,39 @@ func (t *loginAttemptTracker) recordFailure(ip string, now time.Time) time.Durat state.Failures = nil state.BlockedUntil = now.Add(webLoginBlockDuration) t.attempts[normalized] = state + t.sweepExpiredLocked(now) return webLoginBlockDuration } t.attempts[normalized] = state + t.sweepExpiredLocked(now) return 0 } +// sweepExpiredLocked 在条目数超过上限时清理已失效的记录。 +// 条目仅在登录成功或同一 key 再次进入 allow() 时才会被删除, +// 分布式来源(僵尸网络)下 map 会单向增长,故补一道容量触发的清扫。 +// 调用方必须已持有 t.mu。 +func (t *loginAttemptTracker) sweepExpiredLocked(now time.Time) { + if len(t.attempts) <= webLoginAttemptTrackerMaxEntries { + return + } + for key, state := range t.attempts { + if state.BlockedUntil.After(now) { + continue + } + fresh := false + for _, failureAt := range state.Failures { + if now.Sub(failureAt) <= webLoginFailureWindow { + fresh = true + break + } + } + if !fresh { + delete(t.attempts, key) + } + } +} + func (t *loginAttemptTracker) recordSuccess(ip string) { normalized := strings.TrimSpace(ip) if normalized == "" { @@ -977,16 +1005,92 @@ func isSecureRequest(r *http.Request) bool { return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Ssl")), "on") } -func clientIP(r *http.Request) string { +// webTrustedProxiesEnvName 配置可信反向代理的 IP / CIDR 列表(逗号分隔)。 +// 只有当直连对端命中该列表时,X-Forwarded-For 才会被采信。 +const webTrustedProxiesEnvName = "GONAVI_WEB_TRUSTED_PROXIES" + +var ( + trustedProxyOnce sync.Once + trustedProxyNets []*net.IPNet +) + +func loadTrustedProxyNets() []*net.IPNet { + trustedProxyOnce.Do(func() { + raw := strings.TrimSpace(os.Getenv(webTrustedProxiesEnvName)) + if raw == "" { + return + } + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, network, err := net.ParseCIDR(part); err == nil && network != nil { + trustedProxyNets = append(trustedProxyNets, network) + continue + } + if ip := net.ParseIP(part); ip != nil { + bits := 32 + if ip.To4() == nil { + bits = 128 + } + trustedProxyNets = append(trustedProxyNets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) + } + } + }) + return trustedProxyNets +} + +func isTrustedProxyAddr(text string) bool { + ip := net.ParseIP(strings.TrimSpace(text)) + if ip == nil { + return false + } + for _, network := range loadTrustedProxyNets() { + if network.Contains(ip) { + return true + } + } + return false +} + +// peerIP 返回不可伪造的直连对端地址。 +func peerIP(r *http.Request) string { if r == nil { return "" } - if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" { - return forwarded - } host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) if err == nil && strings.TrimSpace(host) != "" { - return host + return strings.TrimSpace(host) } return strings.TrimSpace(r.RemoteAddr) } + +// clientIP 返回用于登录限流与锁定的客户端标识。 +// +// 必须以不可伪造的对端地址为准:X-Forwarded-For 完全由客户端控制,原实现无条件采信其首值, +// 直连攻击者每次请求换一个值即可拿到全新的限流桶,从而彻底绕过 5 次失败锁定 +// (口令下限仅 webMinPasswordLength=6,可在线爆破),并使 attempts map 无界增长; +// 同时每次尝试都会在校验路径上触发一次 64 MiB 的 Argon2id 推导,形成认证面的 CPU/内存 DoS。 +// +// 仅当对端 IP 命中 GONAVI_WEB_TRUSTED_PROXIES 时,才从 X-Forwarded-For 里取「最右侧的 +// 非可信跳」作为真实客户端——右侧是代理追加的、可信的部分,左侧可被客户端预先伪造。 +func clientIP(r *http.Request) string { + peer := peerIP(r) + if peer == "" || !isTrustedProxyAddr(peer) { + return peer + } + parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",") + for i := len(parts) - 1; i >= 0; i-- { + candidate := strings.TrimSpace(parts[i]) + if candidate == "" || isTrustedProxyAddr(candidate) { + continue + } + if net.ParseIP(candidate) == nil { + // 非法值不予采信,避免攻击者用任意字符串制造新的限流桶。 + continue + } + return candidate + } + return peer +} diff --git a/internal/webserver/auth_client_ip_test.go b/internal/webserver/auth_client_ip_test.go new file mode 100644 index 00000000..df954167 --- /dev/null +++ b/internal/webserver/auth_client_ip_test.go @@ -0,0 +1,133 @@ +package webserver + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +// resetTrustedProxyCache 让每个用例都能重新读取 GONAVI_WEB_TRUSTED_PROXIES。 +func resetTrustedProxyCache(t *testing.T) { + t.Helper() + trustedProxyOnce = sync.Once{} + trustedProxyNets = nil + t.Cleanup(func() { + trustedProxyOnce = sync.Once{} + trustedProxyNets = nil + }) +} + +func requestWithXFF(remoteAddr string, xff string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/__gonavi/auth/login", nil) + req.RemoteAddr = remoteAddr + if xff != "" { + req.Header.Set("X-Forwarded-For", xff) + } + return req +} + +// TestClientIPIgnoresForwardedHeaderFromUntrustedPeer 覆盖限流标识的抗伪造。 +// +// 回归背景:clientIP 原先无条件采信 X-Forwarded-For 的首值。该头完全由客户端控制, +// 直连攻击者每次请求换一个值即可拿到全新的限流桶,彻底绕过 5 次失败锁定 +// (口令下限仅 6 位,可在线爆破),并使 attempts map 无界增长; +// 同时每次尝试都会触发一次 64 MiB 的 Argon2id 推导,形成认证面 CPU/内存 DoS。 +func TestClientIPIgnoresForwardedHeaderFromUntrustedPeer(t *testing.T) { + resetTrustedProxyCache(t) + t.Setenv(webTrustedProxiesEnvName, "") + + // 攻击者直连并伪造 XFF:必须回落到不可伪造的对端地址。 + for _, spoof := range []string{"1.2.3.1", "1.2.3.2", "9.9.9.9, 8.8.8.8", "not-an-ip"} { + req := requestWithXFF("203.0.113.7:51000", spoof) + if got := clientIP(req); got != "203.0.113.7" { + t.Errorf("XFF=%q 时 clientIP = %q,期望回落到对端 203.0.113.7", spoof, got) + } + } + + // 无 XFF 时同样取对端地址。 + if got := clientIP(requestWithXFF("203.0.113.7:51000", "")); got != "203.0.113.7" { + t.Errorf("无 XFF 时 clientIP = %q,期望 203.0.113.7", got) + } +} + +// TestClientIPHonorsForwardedHeaderOnlyBehindTrustedProxy 可信代理下才采信 XFF, +// 且取最右侧的非可信跳(左侧可被客户端预先伪造)。 +func TestClientIPHonorsForwardedHeaderOnlyBehindTrustedProxy(t *testing.T) { + resetTrustedProxyCache(t) + t.Setenv(webTrustedProxiesEnvName, "10.0.0.0/8, 192.168.1.5") + + // 对端是可信代理:XFF 最右侧非可信跳即真实客户端。 + if got := clientIP(requestWithXFF("10.0.0.3:40000", "1.1.1.1, 203.0.113.9")); got != "203.0.113.9" { + t.Errorf("可信代理后 clientIP = %q,期望最右非可信跳 203.0.113.9", got) + } + // 链中夹着可信代理时应跳过它们。 + if got := clientIP(requestWithXFF("10.0.0.3:40000", "203.0.113.9, 10.0.0.9")); got != "203.0.113.9" { + t.Errorf("clientIP = %q,期望跳过可信跳得到 203.0.113.9", got) + } + // 单 IP 形式的可信代理配置同样生效。 + if got := clientIP(requestWithXFF("192.168.1.5:40000", "203.0.113.9")); got != "203.0.113.9" { + t.Errorf("单 IP 可信代理下 clientIP = %q,期望 203.0.113.9", got) + } + // XFF 里全是非法值时回落到对端,不得用任意字符串制造新桶。 + if got := clientIP(requestWithXFF("10.0.0.3:40000", "garbage, also-garbage")); got != "10.0.0.3" { + t.Errorf("XFF 全非法时 clientIP = %q,期望回落 10.0.0.3", got) + } + // 未配置为可信的对端即使发 XFF 也不采信。 + if got := clientIP(requestWithXFF("203.0.113.7:51000", "203.0.113.9")); got != "203.0.113.7" { + t.Errorf("非可信对端 clientIP = %q,期望 203.0.113.7", got) + } +} + +// TestLoginLockoutCannotBeBypassedByRotatingForwardedHeader 端到端断言锁定不可绕过: +// 同一对端无论怎么换 XFF,达到失败上限后都必须被锁。 +func TestLoginLockoutCannotBeBypassedByRotatingForwardedHeader(t *testing.T) { + resetTrustedProxyCache(t) + t.Setenv(webTrustedProxiesEnvName, "") + + tracker := newLoginAttemptTracker() + now := time.Now() + + // 攻击者每次换一个伪造 XFF,但对端固定。 + for i := 0; i < webLoginFailureLimit; i++ { + req := requestWithXFF("203.0.113.7:51000", fmt.Sprintf("1.2.3.%d", i+1)) + key := clientIP(req) + if wait, ok := tracker.allow(key, now); !ok { + t.Fatalf("第 %d 次尝试就被拒绝(wait=%v),预期前 %d 次放行", i+1, wait, webLoginFailureLimit) + } + tracker.recordFailure(key, now) + } + + // 第 6 次:换成又一个新 XFF,仍必须被锁定。 + req := requestWithXFF("203.0.113.7:51000", "1.2.3.99") + wait, ok := tracker.allow(clientIP(req), now) + if ok { + t.Fatal("轮换 X-Forwarded-For 后仍被放行,锁定被绕过") + } + if wait <= 0 { + t.Errorf("锁定剩余时间 = %v,期望大于 0", wait) + } +} + +// TestLoginAttemptTrackerSweepsExpiredEntries 条目数超上限时清理已失效记录, +// 避免分布式来源下 map 单向增长。 +func TestLoginAttemptTrackerSweepsExpiredEntries(t *testing.T) { + tracker := newLoginAttemptTracker() + stale := time.Now().Add(-2 * webLoginFailureWindow) + + for i := 0; i <= webLoginAttemptTrackerMaxEntries; i++ { + tracker.recordFailure(fmt.Sprintf("198.51.100.%d", i), stale) + } + + // 用当前时间再记一次,触发清扫:此前的记录都已超出失败窗口。 + tracker.recordFailure("203.0.113.1", time.Now()) + + tracker.mu.Lock() + size := len(tracker.attempts) + tracker.mu.Unlock() + if size > webLoginAttemptTrackerMaxEntries { + t.Fatalf("清扫后条目数 = %d,仍超过上限 %d", size, webLoginAttemptTrackerMaxEntries) + } +}