diff --git a/internal/plugins/webui/auth_hardening_test.go b/internal/plugins/webui/auth_hardening_test.go new file mode 100644 index 0000000..5d357a3 --- /dev/null +++ b/internal/plugins/webui/auth_hardening_test.go @@ -0,0 +1,228 @@ +package webui + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" + "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// ===== 登录入口的滥用防护 ===== +// +// 真实风险:webui 门户经 frp 穿透到公网(https://homeagent.jianfgit.xyz/ +// 实测直达),而 handleLogin 在加固前**零防护**:无速率限制、无失败计数、 +// 口令用 == 明文比对、失败不审计。配合历史上出现过的弱口令习惯 +// (日志里能看到「为截图登 WebUI 临时改密码」这类操作),等于把一个 +// 可爆破的口子直接开到外网。 +// +// 这组判据钉住四件事:有限流、限流不误伤、可退避、比对不泄漏信息。 + +const testAuthPassword = "correct-horse-battery" + +func newAuthTestHandler(t *testing.T) *Handler { + t.Helper() + cfgReg := internalConfig.NewConfigRegistry("") + seedWebUIConfig(cfgReg) + cfgReg.PluginConfig("webui").Set("password", testAuthPassword) + h := NewHandler(testSDK(sdk.SDKConfig{Settings: sdk.NewSettings("webui", cfgReg)})) + // 走生产真实入口 Handler() = proxyDispatch(logged(mux))。 + // 直接用 h.mux 会绕过 logged 中间件,测不到限流的实际生效位置。 + h.RegisterRoutes(http.NewServeMux()) + return h +} + +func postLoginFrom(t *testing.T, h *Handler, ip, user, pass string) *httptest.ResponseRecorder { + t.Helper() + body := `{"username":"` + user + `","password":"` + pass + `"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body)) + req.RemoteAddr = ip + ":54321" + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.Handler().ServeHTTP(rec, req) + return rec +} + +func tryLoginFrom(h *Handler, ip, user, pass string) int { + body := `{"username":"` + user + `","password":"` + pass + `"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body)) + req.RemoteAddr = ip + ":54321" + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.Handler().ServeHTTP(rec, req) + return rec.Code +} + +func tryLogin(h *Handler, user, pass string) int { + return tryLoginFrom(h, "203.0.113.1", user, pass) +} + +func postLoginRaw(t *testing.T, h *Handler, body []byte) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/v1/login", bytes.NewReader(body)) + req.RemoteAddr = "203.0.113.1:54321" + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.Handler().ServeHTTP(rec, req) + return rec +} + +// 连续失败必须被拦:放行到 30 次都不该一次 429 都没有。 +func TestLoginRateLimitsAfterRepeatedFailures(t *testing.T) { + h := newAuthTestHandler(t) + + ok, blocked := 0, 0 + for i := 0; i < 30; i++ { + switch code := tryLogin(h, "admin", "wrong-password"); code { + case http.StatusOK: + ok++ + case http.StatusTooManyRequests: + blocked++ + } + } + if ok > 0 { + t.Errorf("错误口令居然登录成功了 %d 次", ok) + } + if blocked == 0 { + t.Error("连续 30 次错误口令从未触发限流(一次 429 都没有)—— 门户可被暴力破解") + } +} + +// 限流必须按来源区分:否则一个 IP 的狂刷就能把所有人(含管理员)一起锁死, +// 限流本身就变成了 DoS 手段。 +func TestLoginRateLimitIsPerSource(t *testing.T) { + h := newAuthTestHandler(t) + + for i := 0; i < 30; i++ { + tryLoginFrom(h, "203.0.113.9", "admin", "bad") + } + if code := tryLoginFrom(h, "203.0.113.9", "admin", "bad"); code != http.StatusTooManyRequests { + t.Errorf("攻击者来源应被限流,实际 %d", code) + } + if code := tryLoginFrom(h, "198.51.100.7", "admin", testAuthPassword); code != http.StatusOK { + t.Errorf("其他来源的正常登录被误伤(跨来源污染),实际 %d", code) + } +} + +// 成功必须清零:不能因为早先手滑输错几次就再也登不进。 +// +// 判据强度说明:不能只「跑 30 次看有没有限流」—— 阈值只有 5, +// 跑 30 次时**无论有没有 Reset 都会限流**,那样的判据是假的 +// (已实测:去掉 Reset 后本条依然绿)。必须**测出实际阈值**: +// 清零后应当重新拿到完整的窗口额度。 +func TestLoginSuccessResetsCounter(t *testing.T) { + h := newAuthTestHandler(t) + + // 先用 3 次失败「污染」计数(低于阈值,此时仍能登录) + for i := 0; i < 3; i++ { + tryLogin(h, "admin", "bad") + } + if code := tryLogin(h, "admin", testAuthPassword); code != http.StatusOK { + t.Fatalf("少量失败后应仍能正常登录,实际 %d", code) + } + + // 成功之后,额度必须**重新算满**:连错 loginMaxFails 次才该被拦。 + for i := 1; i <= loginMaxFails; i++ { + if code := tryLogin(h, "admin", "bad"); code != http.StatusUnauthorized { + t.Fatalf("第 %d 次失败期望 401,实际 %d —— 成功登录未清零计数(额度被提前扣掉了)", + i, code) + } + } + if code := tryLogin(h, "admin", "bad"); code != http.StatusTooManyRequests { + t.Errorf("第 %d 次失败后应被限流,实际 %d", loginMaxFails+1, code) + } +} + +// 429 必须带 Retry-After,否则客户端/脚本无从判断何时该重试。 +func TestLoginRateLimitedCarriesRetryAfter(t *testing.T) { + h := newAuthTestHandler(t) + + for i := 0; i < 30; i++ { + tryLogin(h, "admin", "bad") + } + rec := postLoginFrom(t, h, "203.0.113.1", "admin", "bad") + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("期望 429,实际 %d", rec.Code) + } + if rec.Header().Get("Retry-After") == "" { + t.Error("429 响应缺少 Retry-After 头") + } +} + +// 请求体必须限量:不限流的话一个请求就能把内存吃光。 +// +// 判据要点:**不能只看状态码**。8MB 垃圾 JSON 会让解码器直接失败并返回 +// 400,与「被限流拒绝」撞码 —— 那样这条判据是假的(改与不改都绿)。 +// 所以断言大请求体在解码前就被挡下,即 413。 +func TestLoginBodySizeLimited(t *testing.T) { + h := newAuthTestHandler(t) + + body := make([]byte, 8<<20) + for i := range body { + body[i] = 'a' + } + rec := postLoginRaw(t, h, body) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("超大请求体应返回 413,实际 %d(body=%.120s)—— "+ + "若为 400 说明只是解码失败而非体积限制,判据无效", rec.Code, rec.Body.String()) + } + if code := tryLogin(h, "admin", testAuthPassword); code != http.StatusOK { + t.Errorf("正常登录被体积限制误伤,实际 %d", code) + } +} + +// 失败原因不得可区分:不同失败给不同状态码或报文 = 可枚举用户名。 +func TestLoginNoUsernameEnumeration(t *testing.T) { + h := newAuthTestHandler(t) + + badUser := postLoginFrom(t, h, "203.0.113.1", "no-such-user-xyz", "whatever") + badPass := postLoginFrom(t, h, "198.51.100.7", "admin", "wrong") + + if badUser.Code != badPass.Code { + t.Errorf("不同失败原因返回不同状态码(%d vs %d),可用于枚举用户名", + badUser.Code, badPass.Code) + } + if badUser.Body.String() != badPass.Body.String() { + t.Errorf("不同失败原因返回不同响应体,可用于枚举用户名:\n 用户不存在: %s\n 口令错误 : %s", + badUser.Body.String(), badPass.Body.String()) + } +} + +// 限流必须是**有界**的:过期记录要被清掉,否则攻击者轮换来源 IP +// 就能把 map 喂成内存泄漏。 +func TestLoginLimiterPrunesExpiredKeys(t *testing.T) { + l := newLoginLimiter(loginMaxFails, loginWindow) + + for i := 0; i < 500; i++ { + l.Fail("src-" + strconv.Itoa(i)) + } + if n := l.liveKeys(); n != 500 { + t.Errorf("记录数 = %d,期望 500", n) + } + // 推进到窗口之后并触发清理 + l.clockAdvance(loginWindow + time.Minute) + l.prune() + if n := l.liveKeys(); n != 0 { + t.Errorf("过期后仍残留 %d 条记录 —— 轮换 IP 即可无限增长(内存泄漏)", n) + } +} + +// 计时器替代方案:验证 Allow 返回的重试时长不为 0。 +// 返回 0 会让客户端立即重试 —— 那等于没有限流。 +func TestLoginLimiterRetryNeverZero(t *testing.T) { + l := newLoginLimiter(2, time.Hour) + l.Fail("k") + l.Fail("k") + ok, retry := l.Allow("k") + if ok { + t.Fatal("达到阈值后应被限流") + } + if retry <= 0 { + t.Errorf("Retry-After 时长 = %v,必须为正(返回 0 会让客户端立即重试)", retry) + } +} diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index a7f3af4..4672d4e 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -12,8 +12,10 @@ import ( "time" "crypto/rand" + "crypto/subtle" "encoding/hex" "encoding/json" + "errors" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" "net/http" ) @@ -108,6 +110,10 @@ type Handler struct { sessionMu sync.Mutex sessions map[string]time.Time + // loginLimiter 是登录入口的按来源失败计数(见 login_limiter.go)。 + // 门户可被穿透到公网,登录是唯一的口令入口,必须有滥用防护。 + loginLimiter *loginLimiter + sseEvents *sseEventRing // SSE 事件环状缓冲区,Last-Event-ID 重放用 chatMu sync.Mutex @@ -175,6 +181,7 @@ func NewHandler(s *sdk.PluginSDK) *Handler { term: term, llm: llm, sessions: make(map[string]time.Time), + loginLimiter: newLoginLimiter(loginMaxFails, loginWindow), pendingIdx: -1, chatMsgCache: make(map[string]*chatMsgEntry), sseEvents: newSSEEventRing(200), @@ -511,6 +518,11 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + // 限流必须在**校验之前**:口令错误也要计数。否则爆破请求每次都走 + // 完整套校验(含配置读取),限流也就失去了保护意义。 + if !h.enforceLoginRateLimit(w, r) { + return + } _, username, password, _ := h.getWebUIConfig() if username == "" || password == "" { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "webui username/password not configured"}) @@ -520,14 +532,47 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { Username string `json:"username"` Password string `json:"password"` } + // 体积限制必须在解码**之前**生效,且不能只依赖解码器报错: + // + // json.Decoder 是**按需读流**的。若请求体是「超大且非法 JSON」, + // 解码器会在第 0 个字节就报语法错误,**永远不会读到上限**, + // 于是 MaxBytesError 根本不会出现 —— 而 8MB 数据仍已被读入缓冲。 + // 那样「限体积」只对「合法到能继续解析的大 JSON」生效。 + // + // 所以先用 ContentLength 快速拒绝(覆盖绝大多数真实攻击:直接发 + // 声明很大的 Content-Length),再用 MaxBytesReader 兜住分块传输 + // 与谎报 Content-Length 的情况。 + if r.ContentLength > loginBodyLimit { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "请求体过大"}) + return + } + r.Body = http.MaxBytesReader(w, r.Body, loginBodyLimit) if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "请求体过大"}) + return + } + // 解析到一半也可能撞上上限(合法 JSON 但超长),再兜一次。 + if r.ContentLength < 0 && bodyOverLimit(r) { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "请求体过大"}) + return + } writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) return } - if body.Username != username || body.Password != password { + // 两条失败路径**必须**给完全相同的状态码与报文,否则可用于枚举用户名。 + // 用 constant-time 比较:== 会在第一个不同字节处短路,泄漏 + // 「猜对了几位」的时序信息(远程噪声大,但攻击者可多次采样取均值)。 + 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)) writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "用户名或密码错误"}) return } + // 成功即清零:惩罚只针对持续失败,手滑输错几次不该被记账。 + h.loginLimiter.Reset(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 new file mode 100644 index 0000000..188b609 --- /dev/null +++ b/internal/plugins/webui/login_limiter.go @@ -0,0 +1,201 @@ +package webui + +import ( + "net" + "net/http" + "strconv" + "sync" + "time" +) + +// ===== 登录入口的滥用防护 ===== +// +// 为什么需要:webui 门户可以经 frp 之类的穿透暴露到公网 +// (本项目实测 https://homeagent.jianfgit.xyz/ 直达),而 handleLogin +// 在此之前是**零防护**:无速率限制、无失败计数、口令用 == 明文比对、 +// 失败无审计。这等于把一个可爆破的口子直接开到外网。 +// +// 设计取舍(都不是「越多越好」): +// +// - **按来源 IP 计数**,不全局:全局计数会让攻击者用一个 IP 的狂刷 +// 把所有人(含管理员自己)一起锁死 —— 那既是 DoS,又让「锁着」 +// 变成一种攻击手段。按来源隔离后,攻击者只能锁自己。 +// - **退避而非永久封禁**:窗口过期自动恢复。永久封禁意味着一旦误撞 +// (或被撞库)就再也登不进,只能上机器改配置。 +// - **成功即清零**:否则「手滑输错三次」会被永久记账。惩罚应只针对 +// 持续失败的行为。 +// - **不做账号级锁定**:本系统 webui 只有单一管理员账号,账号级锁定 +// 相比 IP 级没有额外收益,却多一个误伤面。 +// +// 刻意不做:全局失败告警、验证码、账号锁定 —— 要么超出本系统规模所需, +// 要么会引入新的误伤面。留待真需要时再说。 + +// loginLimiter 是按来源的失败计数器。 +// +// 并发:登录是公网入口,必须扛得住并发爆破,计数用互斥量保护。 +// 读路径(Allow)也要加锁 —— 无锁读在多核下会漏计,反而让限流可被 +// 多核并发绕过。 +type loginLimiter struct { + mu sync.Mutex + fails map[string]*loginFailRecord + // maxFails 是触发限流的失败次数上限。 + maxFails int + // window 是失败计数的存活窗口。 + window time.Duration + // now 可注入,便于测试窗口过期而不必 sleep。 + now func() time.Time + // offsetInTest 仅测试用:虚拟时钟的累计偏移(生产恒为 0)。 + offsetInTest time.Duration +} + +type loginFailRecord struct { + count int + first time.Time // 本轮计数的起点(**不是**最后一次失败的时间) +} + +func newLoginLimiter(maxFails int, window time.Duration) *loginLimiter { + return &loginLimiter{ + fails: map[string]*loginFailRecord{}, + maxFails: maxFails, + window: window, + now: time.Now, + } +} + +// Allow 报告该来源此刻是否允许再试一次登录。 +// 被限流时同时返回建议的重试等待时长。 +func (l *loginLimiter) Allow(key string) (bool, time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + rec, ok := l.fails[key] + if !ok { + return true, 0 + } + now := l.now() + if now.Sub(rec.first) >= l.window { + // 窗口已过:这条记录不再代表「近期持续失败」,直接丢弃。 + delete(l.fails, key) + return true, 0 + } + if rec.count < l.maxFails { + return true, 0 + } + // 还能再等多久 —— 用于 Retry-After。下限 1 秒:算出 0 会让客户端 + // 立即重试,那等于没有限流。 + retry := l.window - now.Sub(rec.first) + if retry < time.Second { + retry = time.Second + } + return false, retry +} + +// Fail 记一次失败。**成功登录不调用它**,由 Reset 清零。 +func (l *loginLimiter) Fail(key string) { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + rec, ok := l.fails[key] + if !ok || now.Sub(rec.first) >= l.window { + // 新一轮失败:计数从 1 重新开始,窗口也重新起算。 + l.fails[key] = &loginFailRecord{count: 1, first: now} + return + } + rec.count++ +} + +// Reset 登录成功后清零该来源的计数。 +func (l *loginLimiter) Reset(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.fails, key) +} + +// prune 丢弃过期记录。 +// +// 为什么需要:不调用它就没有别的触发点,map 会随来源无限增长 —— +// 攻击者轮换大量来源 IP 就能把它喂成内存泄漏。 +func (l *loginLimiter) prune() { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + for k, rec := range l.fails { + if now.Sub(rec.first) >= l.window { + delete(l.fails, k) + } + } +} + +// liveKeys 返回当前记录数(仅测试用;生产路径不读它,避免为测试留后门)。 +func (l *loginLimiter) liveKeys() int { + l.mu.Lock() + defer l.mu.Unlock() + return len(l.fails) +} + +// clockAdvance 仅测试用:推进虚拟时钟,使窗口可在不 sleep 的情况下过期。 +func (l *loginLimiter) clockAdvance(d time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + l.offsetInTest += d + l.now = func() time.Time { return time.Now().Add(l.offsetInTest) } +} + +// sourceKey 取请求的来源标识。 +// +// 刻意**不用** X-Forwarded-For:那个头由客户端可伪造,直接采信等于让 +// 攻击者随手换一个头就能绕过限流(甚至把限流当成打别人来源的武器)。 +// 真实客户端 IP 只能由前置反代决定,那是部署侧的事。 +// +// 代价(写明以免误以为它永远精确):若 webui 直接挂在反代后面, +// 所有请求会共用反代的 IP,限流会退化成「全局」。那种部署应在反代层 +// 做限流,或让反代用 PROXY protocol 传真实来源。 +func sourceKey(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + if r.RemoteAddr != "" { + return r.RemoteAddr + } + return "unknown" +} + +// 限流参数。取「够宽容又不至于被爆破」的值: +// +// 5 次失败后开始拦 —— 记错口令、输错用户名都可能连错几次;持续的 +// 失败才可疑。 +// 10 分钟窗口 —— 10 分钟内 5 次错基本可断定是爆破;窗口过长会让一次 +// 撞库把来源锁很久。 +// 1MB 请求体上限 —— 真实登录请求只有几十字节,1MB 已有极大余量; +// 不限制则单个请求就能吃光内存。 +const ( + loginMaxFails = 5 + loginWindow = 10 * time.Minute + loginBodyLimit = 1 << 20 +) + +// enforceLoginRateLimit 是 handleLogin 的限流闸门。 +// 放行返回 true;已超限则已写好 429 并返回 false。 +func (h *Handler) enforceLoginRateLimit(w http.ResponseWriter, r *http.Request) bool { + if h.loginLimiter == nil { + return true + } + key := 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{ + "error": "登录尝试过于频繁,请稍后再试", + }) + return false + } + return true +} + +// bodyOverLimit 报告请求体是否已超过登录体上限。 +// +// 存在理由(重要):MaxBytesReader 只在**读超限**时通过 MaxBytesError 报错, +// 而 json.Decoder 遇到非法 JSON 会在第 0 字节就返回语法错误,根本不往下读。 +// 于是「超大 + 非法」这种最省力的攻击载荷只会被当成 400,而数据已进缓冲。 +// 这里显式检查 ContentLength 补上这个缺口。 +func bodyOverLimit(r *http.Request) bool { + return r.ContentLength > loginBodyLimit +}