fix(webui): 限流来源识别改为「只信任受信反代的 XFF」—— 修复把自己锁在门外

★ 这是在生产上亲手踩出来的:上一提交加了按 IP 限流后,我用 8 次错误登录
做验证,结果**把管理员自己锁在外面 10 分钟**。

## 现场证据

  [webui] POST /api/v1/login from=127.0.0.1 auth=none status=429

webui 经 frp/nginx 穿透到公网时,**所有外部请求的 RemoteAddr 都是
127.0.0.1**。于是所有人共用一个桶:任何人爆破 5 次,就把**所有人**
(含管理员)一起锁死。限流从防护变成了 DoS。

## 我第一版还犯了个方向的错

当时我刻意**不采信** X-Forwarded-For,理由是「该头可伪造,换个头就能
绕过限流」。这个理由本身对,但结论下反了:完全不采信,在穿透部署下
**必然退化成全局限流** —— 而全局限流正是我试图避免的那个后果。

## 正确做法:中间路线

**只信任受信反代发来的 XFF**。判定「是否来自受信反代」不能靠
内网/回环 IP 猜 —— 穿透部署下反代恰恰就在本机 127.0.0.1,与直连请求
完全同源,猜不出来。所以由部署方**显式声明**(新设置项
`webui.trusted_proxies`,逗号分隔 CIDR 或裸 IP)。

权衡写明:未声明时穿透明场景下限流退化为「全局」。这是**刻意的保守
默认** —— 宁可限流偏保守,也不能因为采信伪造头而形同虚设。

## 判据(+4)

- TestLoginRateLimitUsesForwardedForFromTrustedProxy:受信反代下按真实
  客户端 IP 隔离(否则就是全局锁)
- TestLoginRateLimitIgnoresUntrustedForwardedFor:换 XFF 头不得绕过限流
- TestLoginRateLimitNeedsExplicitTrustedProxyConfig:未配置 = 不采信
- TestParseTrustedProxies:合法项接受、非法项丢弃、空 = nil

全量:35 包全绿。

★ 附带教训(也记在判据注释里):**用失败注入做验证时要意识到副作用
范围**。我那次「跑 8 次错误密码看看会不会限流」本身是合理的验证动作,
但它作用在**生产实例**上,且限流的作用域(全局化)正好覆盖了自己。
在带状态的安全机制上做破坏性验证,判据应该先证明作用域是对的。
This commit is contained in:
JianFeeeee
2026-09-26 09:55:54 +08:00
parent 667b9fdc8a
commit 35df6f4366
4 changed files with 209 additions and 11 deletions

View File

@ -226,3 +226,95 @@ func TestLoginLimiterRetryNeverZero(t *testing.T) {
t.Errorf("Retry-After 时长 = %v,必须为正(返回 0 会让客户端立即重试)", retry)
}
}
// ===== 限流的来源识别:穿透部署下不能把所有人算成一个 =====
//
// ★ 这是我在生产上亲手踩出来的:加了按 IP 限流后,跑 8 次错误登录做验证,
// 结果**把管理员自己锁在外面 10 分钟**。
//
// 原因:webui 经 frp/nginx 穿透到公网,所有外部请求的 RemoteAddr 都是
// 127.0.0.1(日志实证:from=127.0.0.1 status=429)。于是所有人共用一个桶,
// 任何人爆破 5 次,**所有人**(含管理员)一起被锁 —— 限流反而成了 DoS。
//
// 正确做法不是「不信 XFF」(那正是我第一版的做法,会退化成全局限流),
// 而是:**只信任来自受信反代的 X-Forwarded-For**。受信判定不能靠 IP 名单
// 猜(穿透场景下反代就在本机 127.0.0.1),得由配置显式声明。
// 经受信反代时,必须按 XFF 里的真实客户端 IP 计数。
func TestLoginRateLimitUsesForwardedForFromTrustedProxy(t *testing.T) {
h := newAuthTestHandler(t)
h.trustedProxies = []string{"127.0.0.1/32", "::1/128"}
// 攻击者(XFF 声明的来源)狂刷
for i := 0; i < 30; i++ {
tryLoginXFF(h, "203.0.113.66", "admin", "bad")
}
// 受害者:不同的 XFF 声明 + 正确口令 → 不该被牵连
if code := tryLoginXFF(h, "198.51.100.23", "admin", testAuthPassword); code != http.StatusOK {
t.Errorf("受信反代下,不同真实客户端被牵连(限流退化成全局),实际 %d", code)
}
}
// 不受信来源的 XFF 必须被忽略:否则任何人都能换一个头就绕过限流
// (甚至把限流当成打别人来源的武器)。
func TestLoginRateLimitIgnoresUntrustedForwardedFor(t *testing.T) {
h := newAuthTestHandler(t)
// 显式配置为「无受信反代」
h.trustedProxies = nil
for i := 0; i < 30; i++ {
tryLoginXFF(h, "203.0.113.66", "admin", "bad")
}
// 换一个 XFF 继续试:来源未被认可,应仍然被限流
if code := tryLoginXFF(h, "198.51.100.23", "admin", "bad"); code != http.StatusTooManyRequests {
t.Errorf("换 XFF 头就绕过了限流,实际 %d —— 说明采信了不可信的 XFF", code)
}
}
// 反代在**同一台机器**上(穿透部署的常态)时,若未配置受信反代,
// 必须仍能识别不同客户端 —— 否则默认配置就把限流变成了全局锁。
// 判据:未配置时退化到「有 XFF 就用第一个非内网地址」?不行 —— 那等于
// 无条件采信。所以这里钉的是另一个行为:**必须显式配置才能生效**,
// 且未配置时的行为要与「无反代」场景一致(全部算同一个来源)。
func TestLoginRateLimitNeedsExplicitTrustedProxyConfig(t *testing.T) {
h := newAuthTestHandler(t)
h.trustedProxies = nil // 未配置
// 未配置 = 不采信 XFF ⇒ 两个不同 XFF 视为同一来源(127.0.0.1)
for i := 0; i < 6; i++ {
tryLoginXFF(h, "203.0.113.66", "admin", "bad")
}
if code := tryLoginXFF(h, "198.51.100.23", "admin", "bad"); code != http.StatusTooManyRequests {
t.Errorf("未配置受信反代时,XFF 不应被采信(应视为同一来源),实际 %d", code)
}
}
// tryLoginXFF 带 X-Forwarded-For 的登录。
func tryLoginXFF(h *Handler, xff, user, pass string) int {
body := `{"username":"` + user + `","password":"` + pass + `"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.RemoteAddr = "127.0.0.1:54321" // 穿透场景:反代在本机
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", xff)
rec := httptest.NewRecorder()
h.Handler().ServeHTTP(rec, req)
return rec.Code
}
// parseTrustedProxies 解析要稳:合法项接受,非法项被丢弃(而不是让整份
// 配置静默失效)。
func TestParseTrustedProxies(t *testing.T) {
got := parseTrustedProxies(" 127.0.0.1 , 10.0.0.0/8 ,, ::1 ")
if len(got) != 3 {
t.Errorf("应解析出 3 项,实际 %d: %v", len(got), got)
}
// 非法项被丢弃
got = parseTrustedProxies("127.0.0.1,999.999.999.999,10.0.0.0/33")
if len(got) != 1 || got[0] != "127.0.0.1" {
t.Errorf("非法项未被丢弃,实际 %v", got)
}
// 空串 → nil(不采信任何 XFF)
if parseTrustedProxies(" ") != nil {
t.Error("空白配置应返回 nil(保守默认:不采信 XFF)")
}
}