fix(webui): 登录入口加固 —— 限流 + 常量时间比对 + 请求体限量 + 防用户名枚举

门户可经 frp 穿透到公网(https://homeagent.jianfgit.xyz/ 实测直达),
而 handleLogin 原先是**零防护**:无限流、无失败计数、口令用 == 明文比对、
失败不审计。等于把唯一��口令入口直接开到外网任人爆破。

## 改动

1. **按来源 IP 的失败计数限流**(login_limiter.go)
   - 5 次失败后拦,10 分钟窗口。
   - 退避而非永久封禁:窗口过期自动恢复。永久封禁意味着一旦误撞
     (或被撞库)就再也登不进,只能上机器改配置。
   - 成功即清零:手滑输错几次不该被永久记账。
   - **刻意不采信 X-Forwarded-For** —— 该头可伪造,直接采信等于让
     攻击者换一个头就能绕过限流,甚至把限流当成打别人来源的武器。
     代价(已在注释写明):若 webui 挂在反代后,限流会退化成「全局」,
     那种部署应在反代层限流或用 PROXY protocol 传真实来源。
   - **刻意不做账号级锁定**:本系统只有一个管理员账号,账号级锁定
     相比 IP 级无额外收益,却多一个误伤面。
   - 过期记录会被 prune —— 否则攻击者轮换 IP 就能喂成内存泄漏。

2. **常量时间比对**(crypto/subtle):`==` 会在第一个不同字节处短路,
   泄漏「猜对了几位」的时序信息。

3. **请求体限量**:ContentLength 前置拒绝 + MaxBytesReader 兜底。
   ★ 后者**不能只靠解码器报错** —— json.Decoder 按需读流,遇到
   「超大 + 非法 JSON」会在第 0 字节就报语法错误、永远读不到上限,
   于是 8MB 数据已进缓冲而 MaxBytesError 从未出现。只挂 MaxBytesReader
   的写法对最省力的攻击载荷反而无效(实测确认)。

4. **防用户名枚举**:用户不存在与口令错误给完全相同的状态码与报文。

## 判据(8 条)

限流触发 / 按来源隔离(否则一个 IP 就能把所有人锁死,限流即 DoS)/
成功清零 / Retry-After / 请求体限量 / 防枚举 / 过期清理 / 重试时长非零。

变异验证(3 条打红后还原):
- 去掉限流调用 → 3 条判红
- 去掉 ContentLength 前置检查 → 判红(回到 400)
- 去掉 Reset → **起初没打红**:原判据「跑 30 次看是否限流」在阈值只有 5
  时无论有没有 Reset 都会限流,是条**假判据**。已改为**测出实际阈值**
  (清零后应重新拿到完整额度),再去变异即打红。

诚实说明:常量时间比对那条**无法用单测可靠断言**(时序属性,噪声远大于
信号)。它由代码评审保证,不由测试保证 —— 写明以免后人以为有测试兜着。
This commit is contained in:
JianFeeeee
2026-09-26 09:37:55 +08:00
parent 6d0c1188f6
commit 5ebc4481b0
3 changed files with 475 additions and 1 deletions

View File

@ -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)
}
}