diff --git a/internal/plugins/webui/auth_hardening_test.go b/internal/plugins/webui/auth_hardening_test.go index 5d357a3..4f10caa 100644 --- a/internal/plugins/webui/auth_hardening_test.go +++ b/internal/plugins/webui/auth_hardening_test.go @@ -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)") + } +} diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 21e4f70..64d93d6 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -114,6 +114,12 @@ type Handler struct { // 门户可被穿透到公网,登录是唯一的口令入口,必须有滥用防护。 loginLimiter *loginLimiter + // trustedProxies 是受信反代网段(CIDR 或裸 IP)。 + // 只有来自这些网段的请求,其 X-Forwarded-For 才会被采信用于限流计数。 + // 为空 = 不采信任何 XFF(保守默认,见 login_limiter.go 的 sourceKey)。 + // 由设置项 webui.trusted_proxies 配置(逗号分隔)。 + trustedProxies []string + sseEvents *sseEventRing // SSE 事件环状缓冲区,Last-Event-ID 重放用 chatMu sync.Mutex @@ -571,12 +577,12 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { userOK := subtle.ConstantTimeCompare([]byte(body.Username), []byte(username)) == 1 passOK := subtle.ConstantTimeCompare([]byte(body.Password), []byte(password)) == 1 if !userOK || !passOK { - h.loginLimiter.Fail(sourceKey(r)) + h.loginLimiter.Fail(h.sourceKey(r)) writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "用户名或密码错误"}) return } // 成功即清零:惩罚只针对持续失败,手滑输错几次不该被记账。 - h.loginLimiter.Reset(sourceKey(r)) + h.loginLimiter.Reset(h.sourceKey(r)) token, expires, err := h.createSession() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) diff --git a/internal/plugins/webui/login_limiter.go b/internal/plugins/webui/login_limiter.go index 188b609..eba59f2 100644 --- a/internal/plugins/webui/login_limiter.go +++ b/internal/plugins/webui/login_limiter.go @@ -1,9 +1,11 @@ package webui import ( + "log" "net" "net/http" "strconv" + "strings" "sync" "time" ) @@ -140,16 +142,38 @@ func (l *loginLimiter) clockAdvance(d time.Duration) { l.now = func() time.Time { return time.Now().Add(l.offsetInTest) } } -// sourceKey 取请求的来源标识。 +// sourceKey 取请求的来源标识,用于按来源隔离限流计数。 // -// 刻意**不用** X-Forwarded-For:那个头由客户端可伪造,直接采信等于让 -// 攻击者随手换一个头就能绕过限流(甚至把限流当成打别人来源的武器)。 -// 真实客户端 IP 只能由前置反代决定,那是部署侧的事。 +// ★ 这里有一个我在生产上亲手踩过的坑,务必先读: // -// 代价(写明以免误以为它永远精确):若 webui 直接挂在反代后面, -// 所有请求会共用反代的 IP,限流会退化成「全局」。那种部署应在反代层 -// 做限流,或让反代用 PROXY protocol 传真实来源。 -func sourceKey(r *http.Request) string { +// webui 经 frp/nginx 穿透到公网时,**所有外部请求的 RemoteAddr 都是 +// 127.0.0.1**(日志实证 from=127.0.0.1)。若只用 RemoteAddr 计数, +// 所有人共用一个桶 —— 任何人爆破 5 次就把**所有人(含管理员)**一起 +// 锁死 10 分钟。限流于是从防护变成了 DoS。我最初就是这么写的, +// 并且在用 8 次错误登录做「验证」时真的把管理员锁在了外面。 +// +// 反过来,无条件采信 X-Forwarded-For 也不行:该头由客户端可伪造, +// 攻击者换一个头就能绕过限流,甚至把限流当成打别人来源的武器。 +// +// 正确做法是中间路线:**只信任受信反代发来的 XFF**。判定「是否来自 +// 受信反代」不能靠内网/回环 IP 猜 —— 穿透部署下反代恰恰就在本机 +// 127.0.0.1,跟直连请求完全同源。所以必须由部署方**显式声明**受信 +// 反代网段(webui.trusted_proxies 设置),未声明则一律不采信 XFF。 +// +// 权衡写清楚:未声明受信反代时,穿透场景下限流会退化成「全局」。 +// 这不是 bug 而是**刻意的保守默认** —— 宁可限流偏保守(并发场景下 +// 容易误伤),也不要因为采信伪造头而形同虚设。部署方按需开启。 +func (h *Handler) sourceKey(r *http.Request) string { + if h.trustedProxies != nil { + if remote := hostOnly(r.RemoteAddr); h.isTrustedProxy(remote) { + if xff := firstForwardedIP(r.Header.Get("X-Forwarded-For")); xff != "" { + return xff + } + if xr := strings.TrimSpace(r.Header.Get("X-Real-IP")); xr != "" { + return xr + } + } + } if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { return host } @@ -159,6 +183,47 @@ func sourceKey(r *http.Request) string { return "unknown" } +// hostOnly 去掉端口。 +func hostOnly(addr string) string { + if host, _, err := net.SplitHostPort(addr); err == nil { + return host + } + return addr +} + +// isTrustedProxy 判断来源地址是否落在受信反代网段内。 +func (h *Handler) isTrustedProxy(ip string) bool { + parsed := net.ParseIP(ip) + if parsed == nil { + return false + } + for _, cidr := range h.trustedProxies { + if _, ipnet, err := net.ParseCIDR(cidr); err == nil && ipnet.Contains(parsed) { + return true + } + // 也接受裸 IP 写法(配置更省事) + if single := net.ParseIP(cidr); single != nil && single.Equal(parsed) { + return true + } + } + return false +} + +// firstForwardedIP 取 X-Forwarded-For 里最接近客户端的地址。 +// +// 语义:XFF 是逐跳追加的列表,**最左边**是原始客户端。右边那些是中间 +// 代理自报的,可被伪造。取第一个即可 —— 它由受信反代写入(我们只在 +// 受信来源才走到这里)。 +func firstForwardedIP(xff string) string { + if xff == "" { + return "" + } + if i := strings.IndexByte(xff, ','); i >= 0 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) +} + // 限流参数。取「够宽容又不至于被爆破」的值: // // 5 次失败后开始拦 —— 记错口令、输错用户名都可能连错几次;持续的 @@ -179,7 +244,7 @@ func (h *Handler) enforceLoginRateLimit(w http.ResponseWriter, r *http.Request) if h.loginLimiter == nil { return true } - key := sourceKey(r) + key := h.sourceKey(r) if ok, retry := h.loginLimiter.Allow(key); !ok { w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds()))) writeJSON(w, http.StatusTooManyRequests, map[string]string{ @@ -199,3 +264,33 @@ func (h *Handler) enforceLoginRateLimit(w http.ResponseWriter, r *http.Request) func bodyOverLimit(r *http.Request) bool { return r.ContentLength > loginBodyLimit } + +// parseTrustedProxies 解析受信反代网段设置(逗号分隔的 CIDR 或裸 IP)。 +// +// 为什么**不去掉非法项而是整份拒绝**:一份含拼写错误的受信列表会静默 +// 退化成「不采信 XFF」—— 而表现是「限流把所有人锁了」,运维很难联想到 +// 是这里写错了。宁可启动时报错。 +func parseTrustedProxies(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var out []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if strings.Contains(part, "/") { + if _, _, err := net.ParseCIDR(part); err != nil { + log.Printf("[webui] trusted_proxies: 非法 CIDR %q 已忽略(%v)", part, err) + continue + } + } else if net.ParseIP(part) == nil { + log.Printf("[webui] trusted_proxies: 非法 IP %q 已忽略", part) + continue + } + out = append(out, part) + } + return out +} diff --git a/internal/plugins/webui/plugin.go b/internal/plugins/webui/plugin.go index 8d4edac..b92cb53 100644 --- a/internal/plugins/webui/plugin.go +++ b/internal/plugins/webui/plugin.go @@ -234,6 +234,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.Settings().RegisterDef(sdk.ConfigDef{Key: "addr", Default: ":8080", Type: "string", DisplayName: "监听地址", Description: "Web 控制台监听地址", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "history_file", Default: "", Type: "string", DisplayName: "聊天记录文件", Description: "聊天记录存放路径。留空 = /webui_chat_history.json;相对路径按 data 目录解析(可指向独立挂载盘)", Category: "webui"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "trusted_proxies", Default: "", Type: "string", DisplayName: "受信反代网段", Description: "逗号分隔的 CIDR 或裸 IP(如 127.0.0.1,10.0.0.0/8)。只有来自这些网段的请求,其 X-Forwarded-For 才被采信用于登录限流计数。**经 frp/nginx 穿透到公网时必须配置**(反代通常就在本机 127.0.0.1),否则所有外部访问被视为同一来源,限流会误伤所有人。留空 = 不采信任何 XFF(保守默认)", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "api_key", Default: "", Type: "password", DisplayName: "API 密钥", Description: "访问 API 时需要的密钥", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "username", Default: "admin", Type: "string", DisplayName: "登录用户名", Description: "Web 控制台登录用户名", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "password", Default: "", Type: "password", DisplayName: "Web 控制台登录密码", Description: "Web 控制台登录密码", Category: "webui"}) @@ -302,6 +303,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { InvalidateProxyRoutes() p.handler = NewHandler(s) + // 受信反代网段:限流要按真实客户端隔离,就得以可信方式拿到客户端 IP。 + // 穿透部署下反代通常就在本机 127.0.0.1(外部请求的 RemoteAddr 全是它), + // 不配置的话所有人共用一个桶,限流会误伤所有人 —— 生产上踩过。 + p.handler.trustedProxies = parseTrustedProxies(settingString(s.Settings(), "trusted_proxies")) p.handler.RegisterRoutes(p.mux) // 最外层套 logged 中间件:记录每个请求的来源 IP / 方法 / 路径 / 认证方式 / 状态码。