Files
HomeAgent/internal/plugins/webui/auth_hardening_test.go
JianFeeeee 5ebc4481b0 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 都会限流,是条**假判据**。已改为**测出实际阈值**
  (清零后应重新拿到完整额度),再去变异即打红。

诚实说明:常量时间比对那条**无法用单测可靠断言**(时序属性,噪声远大于
信号)。它由代码评审保证,不由测试保证 —— 写明以免后人以为有测试兜着。
2026-09-26 13:49:59 +08:00

229 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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